Compare commits

..

4 Commits

Author SHA1 Message Date
pablohashescobar e6c8d513ed dev: exception handling 2024-01-22 10:11:26 +00:00
pablohashescobar dd95dd9f5e dev: hold celery worker and beat when pending migrations 2024-01-19 12:37:23 +05:30
pablohashescobar e43139726f dev: update example file for local dev 2024-01-19 12:35:59 +05:30
pablohashescobar 020fe91267 dev: create safe migration script so that the migration doesn't fail when running in replicas 2024-01-19 12:35:43 +05:30
240 changed files with 2689 additions and 8688 deletions
+1 -2
View File
@@ -1,8 +1,7 @@
name: Bug report
description: Create a bug report to help us improve Plane
title: "[bug]: "
labels: [🐛bug]
assignees: [srinivaspendem, pushya-plane]
labels: [bug, need testing]
body:
- type: markdown
attributes:
@@ -1,8 +1,7 @@
name: Feature request
description: Suggest a feature to improve Plane
title: "[feature]: "
labels: [feature]
assignees: [srinivaspendem, pushya-plane]
labels: [feature]
body:
- type: markdown
attributes:
+1 -1
View File
@@ -37,7 +37,7 @@ Meet [Plane](https://plane.so). An open-source software development tool to mana
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting/docker-compose).
## ⚡️ Contributors Quick Start
Regular → Executable
+1
View File
@@ -2,4 +2,5 @@
set -e
python manage.py wait_for_db
python manage.py wait_for_migrations
celery -A plane beat -l info
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash
set -e
python manage.py wait_for_db
python manage.py migrate
python manage.py safe_migrate
# Create the default bucket
#!/bin/bash
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash
set -e
python manage.py wait_for_db
python manage.py migrate
python manage.py safe_migrate
# Create the default bucket
#!/bin/bash
+1
View File
@@ -2,4 +2,5 @@
set -e
python manage.py wait_for_db
python manage.py wait_for_migrations
celery -A plane worker -l info
+1 -2
View File
@@ -68,6 +68,7 @@ from .issue import (
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer,
IssueRelationLiteSerializer,
)
from .module import (
@@ -119,5 +120,3 @@ from .notification import NotificationSerializer
from .exporter import ExporterHistorySerializer
from .webhook import WebhookSerializer, WebhookLogSerializer
from .dashboard import DashboardSerializer, WidgetSerializer
+7 -6
View File
@@ -59,7 +59,6 @@ class DynamicBaseSerializer(BaseSerializer):
LabelSerializer,
CycleIssueSerializer,
IssueFlatSerializer,
IssueRelationSerializer,
)
# Expansion mapper
@@ -79,10 +78,14 @@ 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", "issue_relation"] else False)
self.fields[field] = expansion[field](
many=True
if field
in ["members", "assignees", "labels", "issue_cycle"]
else False
)
return self.fields
@@ -102,7 +105,6 @@ class DynamicBaseSerializer(BaseSerializer):
IssueSerializer,
LabelSerializer,
CycleIssueSerializer,
IssueRelationSerializer,
)
# Expansion mapper
@@ -122,7 +124,6 @@ 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:
@@ -1,26 +0,0 @@
# 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"
]
+21 -15
View File
@@ -293,19 +293,31 @@ class IssueLabelSerializer(BaseSerializer):
]
class IssueRelationSerializer(BaseSerializer):
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 IssueRelationLiteSerializer(DynamicBaseSerializer):
project_id = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta:
model = IssueRelation
model = Issue
fields = [
"id",
"project_id",
"sequence_id",
"relation_type",
]
read_only_fields = [
"workspace",
"project",
]
class IssueRelationSerializer(BaseSerializer):
issue_detail = IssueRelationLiteSerializer(
read_only=True, source="related_issue"
)
class Meta:
model = IssueRelation
fields = [
"issue_detail",
]
read_only_fields = [
"workspace",
@@ -314,18 +326,12 @@ class IssueRelationSerializer(BaseSerializer):
class RelatedIssueSerializer(BaseSerializer):
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)
issue_detail = IssueRelationLiteSerializer(read_only=True, source="issue")
class Meta:
model = IssueRelation
fields = [
"id",
"project_id",
"sequence_id",
"relation_type",
"issue_detail",
]
read_only_fields = [
"workspace",
-2
View File
@@ -3,7 +3,6 @@ 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
@@ -29,7 +28,6 @@ urlpatterns = [
*authentication_urls,
*configuration_urls,
*cycle_urls,
*dashboard_urls,
*estimate_urls,
*external_urls,
*importer_urls,
-23
View File
@@ -1,23 +0,0 @@
from django.urls import path
from plane.app.views import DashboardEndpoint, WidgetsEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/dashboard/",
DashboardEndpoint.as_view(),
name="dashboard",
),
path(
"workspaces/<str:slug>/dashboard/<uuid:dashboard_id>/",
DashboardEndpoint.as_view(),
name="dashboard",
),
path(
"dashboard/<uuid:dashboard_id>/widgets/<uuid:widget_id>/",
WidgetsEndpoint.as_view(),
name="widgets",
),
]
-5
View File
@@ -177,8 +177,3 @@ from .webhook import (
WebhookLogsEndpoint,
WebhookSecretRegenerateEndpoint,
)
from .dashboard import (
DashboardEndpoint,
WidgetsEndpoint
)
+1 -1
View File
@@ -325,7 +325,7 @@ class MagicSignInEndpoint(BaseAPIView):
)
user_token = request.data.get("token", "").strip()
key = request.data.get("key", "").strip().lower()
key = request.data.get("key", False).strip().lower()
if not key or user_token == "":
return Response(
-658
View File
@@ -1,658 +0,0 @@
# 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
)
+1
View File
@@ -52,6 +52,7 @@ from plane.app.serializers import (
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer,
IssueRelationLiteSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
+21 -10
View File
@@ -1,5 +1,5 @@
# Python imports
from datetime import date, datetime, timedelta
from datetime import timedelta, date, datetime
# Django imports
from django.db import connection
@@ -7,19 +7,30 @@ 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
from plane.app.permissions import ProjectEntityPermission
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
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,
)
def unarchive_archive_page_and_descendants(page_id, archived_at):
@@ -164,7 +175,7 @@ class PageViewSet(BaseViewSet):
project_id=project_id,
member=request.user,
is_active=True,
role__gte=20,
role__gt=20,
).exists()
or request.user.id != page.owned_by_id
):
+18 -12
View File
@@ -41,7 +41,7 @@ from plane.app.serializers import (
ProjectMemberSerializer,
WorkspaceThemeSerializer,
IssueActivitySerializer,
IssueSerializer,
IssueLiteSerializer,
WorkspaceMemberAdminSerializer,
WorkspaceMemberMeSerializer,
ProjectMemberRoleSerializer,
@@ -1339,10 +1339,23 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
project__project_projectmember__member=request.user,
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.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")
.prefetch_related("assignees", "labels")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.order_by("-created_at")
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -1357,13 +1370,6 @@ 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
@@ -1426,7 +1432,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueSerializer(
issues = IssueLiteSerializer(
issue_queryset, many=True, fields=fields if fields else None
).data
return Response(issues, status=status.HTTP_200_OK)
@@ -0,0 +1,55 @@
# Python imports
import time
import uuid
import atexit
# Django imports
from django.core.management.base import BaseCommand
from django.core.management import call_command
from django.db.migrations.executor import MigrationExecutor
from django.db import connections, DEFAULT_DB_ALIAS
class Command(BaseCommand):
help = 'Wait for migrations to be completed and acquire lock before starting migrations'
def handle(self, *args, **kwargs):
self.lock_key = 'django_migration_lock'
self.lock_value = str(uuid.uuid4()) # Unique value for the lock
self.client = redis_instance()
# Register the cleanup function
atexit.register(self.cleanup)
while self._pending_migrations():
# Try acquiring the lock
if self.client.set(self.lock_key, self.lock_value, nx=True, ex=300): # 5 minutes expiry
try:
self.stdout.write("Acquired migration lock, running migrations...")
call_command('migrate')
except Exception as e:
self.stdout.write(f"An error occurred during migrations: {e}")
finally:
# Release the lock if it belongs to this process
self.cleanup()
return # Exit after attempting migration
else:
self.stdout.write("Migration lock is held by another instance. Waiting 10 seconds to retry...")
time.sleep(10) # Wait for 10 seconds before retrying
self.stdout.write("No pending migrations.")
def _pending_migrations(self):
connection = connections[DEFAULT_DB_ALIAS]
executor = MigrationExecutor(connection)
targets = executor.loader.graph.leaf_nodes()
return bool(executor.migration_plan(targets))
def cleanup(self):
"""
Clean up function to release the lock.
"""
stored_value = self.client.get(self.lock_key)
if stored_value and stored_value.decode() == self.lock_value:
self.client.delete(self.lock_key)
self.stdout.write("Released migration lock.")
@@ -0,0 +1,21 @@
# wait_for_migrations.py
import time
from django.core.management.base import BaseCommand
from django.db.migrations.executor import MigrationExecutor
from django.db import connections, DEFAULT_DB_ALIAS
class Command(BaseCommand):
help = 'Wait for database migrations to complete before starting Celery worker/beat'
def handle(self, *args, **kwargs):
while self._pending_migrations():
self.stdout.write("Waiting for database migrations to complete...")
time.sleep(10) # wait for 10 seconds before checking again
self.stdout.write("All migrations are complete. Safe to start Celery worker/beat.")
def _pending_migrations(self):
connection = connections[DEFAULT_DB_ALIAS]
executor = MigrationExecutor(connection)
targets = executor.loader.graph.leaf_nodes()
return bool(executor.migration_plan(targets))
@@ -1,77 +0,0 @@
# 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')},
},
),
]
@@ -1,97 +0,0 @@
# 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),
]
-2
View File
@@ -90,5 +90,3 @@ from .notification import Notification
from .exporter import ExporterHistory
from .webhook import Webhook, WebhookLog
from .dashboard import Dashboard, DashboardWidget, Widget
-89
View File
@@ -1,89 +0,0 @@
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",)
+13 -11
View File
@@ -7,17 +7,19 @@ 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():
+2 -1
View File
@@ -3,6 +3,7 @@ import uuid
from datetime import timedelta
from django.utils import timezone
# The date from pattern
pattern = re.compile(r"\d+_(weeks|months)$")
@@ -463,7 +464,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 = {}
+5 -3
View File
@@ -76,6 +76,8 @@ services:
- web
api:
deploy:
replicas: 3
build:
context: ./apiserver
dockerfile: Dockerfile.dev
@@ -86,7 +88,7 @@ services:
- dev_env
volumes:
- ./apiserver:/code
# command: /bin/sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local"
command: ./bin/takeoff.local
env_file:
- ./apiserver/.env
depends_on:
@@ -104,7 +106,7 @@ services:
- dev_env
volumes:
- ./apiserver:/code
command: /bin/sh -c "celery -A plane worker -l info"
command: ./bin/worker
env_file:
- ./apiserver/.env
depends_on:
@@ -123,7 +125,7 @@ services:
- dev_env
volumes:
- ./apiserver:/code
command: /bin/sh -c "celery -A plane beat -l info"
command: ./bin/beat
env_file:
- ./apiserver/.env
depends_on:
@@ -5,18 +5,14 @@ interface EditorContainerProps {
editor: Editor | null;
editorClassNames: string;
children: ReactNode;
hideDragHandle?: () => void;
}
export const EditorContainer = ({ editor, editorClassNames, hideDragHandle, children }: EditorContainerProps) => (
export const EditorContainer = ({ editor, editorClassNames, children }: EditorContainerProps) => (
<div
id="editor-container"
onClick={() => {
editor?.chain().focus().run();
}}
onMouseLeave={() => {
hideDragHandle?.();
}}
className={`cursor-text ${editorClassNames}`}
>
{children}
@@ -53,11 +53,6 @@ export const CoreEditorExtensions = (
class: "leading-normal -mb-2",
},
},
hardBreak: {
HTMLAttributes: {
class: "p-2",
},
},
code: false,
codeBlock: false,
horizontalRule: false,
@@ -32,7 +32,6 @@
"@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) => void;
updatePageTitle: (title: string) => Promise<void>;
editor: Editor;
onActionCompleteHandler: (action: {
title: string;
@@ -30,6 +30,18 @@ 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;
@@ -52,9 +64,11 @@ export const PageRenderer = (props: IPageRenderer) => {
const { getFloatingProps } = useInteractions([dismiss]);
const debouncedUpdatePageTitle = debounce(updatePageTitle, 300);
const handlePageTitleChange = (title: string) => {
setPagetitle(title);
updatePageTitle(title);
debouncedUpdatePageTitle(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>\n"
"<p class='text-sm bg-gray-300 w-fit pl-3 pr-3 pt-1 pb-1 rounded shadow-sm'>#issue_</p>"
)
.run();
},
@@ -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 && suggestion.state_detail.name ? suggestion.state_detail.name : "Todo",
state: suggestion.state_detail.name,
command: ({ editor, range }) => {
editor
.chain()
@@ -9,8 +9,6 @@ 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 });
},
@@ -20,8 +18,11 @@ export const IssueEmbedSuggestions = Extension.create({
addProseMirrorPlugins() {
return [
Suggestion({
char: "#issue_",
pluginKey: new PluginKey("issue-embed-suggestions"),
editor: this.editor,
allowSpaces: true,
...this.options.suggestion,
}),
];
@@ -53,7 +53,7 @@ const IssueSuggestionList = ({
const commandListContainer = useRef<HTMLDivElement>(null);
useEffect(() => {
const newDisplayedItems: { [key: string]: IssueSuggestionProps[] } = {};
let 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(
(section: string, index: number) => {
const item = displayedItems[section][index];
(index: number) => {
const item = displayedItems[currentSection][index];
if (item) {
command(item);
}
@@ -87,7 +87,6 @@ const IssueSuggestionList = ({
setSelectedIndex(
(selectedIndex + displayedItems[currentSection].length - 1) % displayedItems[currentSection].length
);
e.stopPropagation();
return true;
}
if (e.key === "ArrowDown") {
@@ -102,12 +101,10 @@ const IssueSuggestionList = ({
[currentSection]: [...prevItems[currentSection], ...nextItems],
}));
}
e.stopPropagation();
return true;
}
if (e.key === "Enter") {
selectItem(currentSection, selectedIndex);
e.stopPropagation();
selectItem(selectedIndex);
return true;
}
if (e.key === "Tab") {
@@ -115,7 +112,6 @@ const IssueSuggestionList = ({
const nextSectionIndex = (currentSectionIndex + 1) % sections.length;
setCurrentSection(sections[nextSectionIndex]);
setSelectedIndex(0);
e.stopPropagation();
return true;
}
return false;
@@ -176,7 +172,7 @@ const IssueSuggestionList = ({
}
)}
key={item.identifier}
onClick={() => selectItem(section, index)}
onClick={() => selectItem(index)}
>
<h5 className="whitespace-nowrap text-xs text-custom-text-300">{item.identifier}</h5>
<PriorityIcon priority={item.priority} />
@@ -199,7 +195,7 @@ export const IssueListRenderer = () => {
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
component = new ReactRenderer(IssueSuggestionList, {
props,
// @ts-ignore
@@ -214,10 +210,10 @@ export const IssueListRenderer = () => {
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
placement: "right",
});
},
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
popup &&
@@ -15,7 +15,8 @@ export const IssueWidgetCard = (props) => {
setIssueDetails(issue);
setLoading(0);
})
.catch(() => {
.catch((error) => {
console.log(error);
setLoading(-1);
});
}, []);
@@ -29,9 +30,7 @@ export const IssueWidgetCard = (props) => {
{loading == 0 ? (
<div
onClick={completeIssueEmbedAction}
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`}
className="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) => void;
updatePageTitle: (title: string) => Promise<void>;
debouncedUpdatesEnabled?: boolean;
isSubmitting: "submitting" | "submitted" | "saved";
@@ -3,7 +3,6 @@ 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");
@@ -31,7 +30,6 @@ function createDragHandleElement(): HTMLElement {
export interface DragHandleOptions {
dragHandleWidth: number;
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
}
function absoluteRect(node: Element) {
@@ -153,8 +151,6 @@ function DragHandle(options: DragHandleOptions) {
}
}
options.setHideDragHandle?.(hideDragHandle);
return new Plugin({
key: new PluginKey("dragHandle"),
view: (view) => {
@@ -242,16 +238,14 @@ function DragHandle(options: DragHandleOptions) {
});
}
export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void) =>
Extension.create({
name: "dragAndDrop",
export const DragAndDrop = Extension.create({
name: "dragAndDrop",
addProseMirrorPlugins() {
return [
DragHandle({
dragHandleWidth: 24,
setHideDragHandle,
}),
];
},
});
addProseMirrorPlugins() {
return [
DragHandle({
dragHandleWidth: 24,
}),
];
},
});
@@ -1,15 +1,14 @@
import { UploadImage } from "@plane/editor-core";
import { DragAndDrop, SlashCommand } from "@plane/editor-extensions";
import { SlashCommand, DragAndDrop } from "@plane/editor-extensions";
import Placeholder from "@tiptap/extension-placeholder";
import { UploadImage } from "@plane/editor-core";
export const RichTextEditorExtensions = (
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
dragDropEnabled?: boolean,
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void
dragDropEnabled?: boolean
) => [
SlashCommand(uploadFile, setIsSubmitting),
dragDropEnabled === true && DragAndDrop(setHideDragHandle),
dragDropEnabled === true && DragAndDrop,
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
@@ -1,4 +1,5 @@
"use client";
import * as React from "react";
import {
DeleteImage,
EditorContainer,
@@ -9,9 +10,8 @@ import {
UploadImage,
useEditor,
} from "@plane/editor-core";
import * as React from "react";
import { RichTextEditorExtensions } from "src/ui/extensions";
import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
import { RichTextEditorExtensions } from "src/ui/extensions";
export type IRichTextEditor = {
value: string;
@@ -66,14 +66,6 @@ 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,
@@ -86,7 +78,7 @@ const RichTextEditor = ({
restoreFile,
forwardedRef,
rerenderOnPropsChange,
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting, dragDropEnabled, setHideDragHandleFunction),
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting, dragDropEnabled),
mentionHighlights,
mentionSuggestions,
});
@@ -100,7 +92,7 @@ const RichTextEditor = ({
if (!editor) return null;
return (
<EditorContainer hideDragHandle={hideDragHandleOnMouseLeave} editor={editor} editorClassNames={editorClassNames}>
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
{editor && <EditorBubbleMenu editor={editor} />}
<div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
@@ -27,7 +27,6 @@ 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)",
@@ -37,8 +36,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)",
"custom-sidebar-shadow-4xl": "var(--color-sidebar-shadow-4xl)",
"onboarding-shadow-sm": "var(--color-onboarding-shadow-sm)",
"onbording-shadow-sm": "var(--color-onboarding-shadow-sm)",
},
colors: {
custom: {
@@ -213,7 +212,7 @@ module.exports = {
to: { left: "100%" },
},
},
typography: () => ({
typography: ({ theme }) => ({
brand: {
css: {
"--tw-prose-body": convertToRGB("--color-text-100"),
@@ -226,12 +225,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-200"),
"--tw-prose-quote-borders": convertToRGB("--color-border"),
"--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-200"),
"--tw-prose-td-borders": convertToRGB("--color-border-200"),
"--tw-prose-th-borders": convertToRGB("--color-border"),
"--tw-prose-td-borders": convertToRGB("--color-border"),
},
},
}),
-175
View File
@@ -1,175 +0,0 @@
import { IIssueActivity, TIssuePriorities } from "./issues";
import { TIssue } from "./issues/issue";
import { TIssueRelationTypes } from "./issues/issue_relation";
import { TStateGroups } from "./state";
export type TWidgetKeys =
| "overview_stats"
| "assigned_issues"
| "created_issues"
| "issues_by_state_groups"
| "issues_by_priority"
| "recent_activity"
| "recent_projects"
| "recent_collaborators";
export type TIssuesListTypes = "upcoming" | "overdue" | "completed";
export type TDurationFilterOptions =
| "today"
| "this_week"
| "this_month"
| "this_year";
// widget filters
export type TAssignedIssuesWidgetFilters = {
target_date?: TDurationFilterOptions;
tab?: TIssuesListTypes;
};
export type TCreatedIssuesWidgetFilters = {
target_date?: TDurationFilterOptions;
tab?: TIssuesListTypes;
};
export type TIssuesByStateGroupsWidgetFilters = {
target_date?: TDurationFilterOptions;
};
export type TIssuesByPriorityWidgetFilters = {
target_date?: TDurationFilterOptions;
};
export type TWidgetFiltersFormData =
| {
widgetKey: "assigned_issues";
filters: Partial<TAssignedIssuesWidgetFilters>;
}
| {
widgetKey: "created_issues";
filters: Partial<TCreatedIssuesWidgetFilters>;
}
| {
widgetKey: "issues_by_state_groups";
filters: Partial<TIssuesByStateGroupsWidgetFilters>;
}
| {
widgetKey: "issues_by_priority";
filters: Partial<TIssuesByPriorityWidgetFilters>;
};
export type TWidget = {
id: string;
is_visible: boolean;
key: TWidgetKeys;
readonly widget_filters: // only for read
TAssignedIssuesWidgetFilters &
TCreatedIssuesWidgetFilters &
TIssuesByStateGroupsWidgetFilters &
TIssuesByPriorityWidgetFilters;
filters: // only for write
TAssignedIssuesWidgetFilters &
TCreatedIssuesWidgetFilters &
TIssuesByStateGroupsWidgetFilters &
TIssuesByPriorityWidgetFilters;
};
export type TWidgetStatsRequestParams =
| {
widget_key: TWidgetKeys;
}
| {
target_date: string;
issue_type: TIssuesListTypes;
widget_key: "assigned_issues";
expand?: "issue_relation";
}
| {
target_date: string;
issue_type: TIssuesListTypes;
widget_key: "created_issues";
}
| {
target_date: string;
widget_key: "issues_by_state_groups";
}
| {
target_date: string;
widget_key: "issues_by_priority";
};
export type TWidgetIssue = TIssue & {
issue_relation: {
id: string;
project_id: string;
relation_type: TIssueRelationTypes;
sequence_id: number;
}[];
};
// widget stats responses
export type TOverviewStatsWidgetResponse = {
assigned_issues_count: number;
completed_issues_count: number;
created_issues_count: number;
pending_issues_count: number;
};
export type TAssignedIssuesWidgetResponse = {
issues: TWidgetIssue[];
count: number;
};
export type TCreatedIssuesWidgetResponse = {
issues: TWidgetIssue[];
count: number;
};
export type TIssuesByStateGroupsWidgetResponse = {
count: number;
state: TStateGroups;
};
export type TIssuesByPriorityWidgetResponse = {
count: number;
priority: TIssuePriorities;
};
export type TRecentActivityWidgetResponse = IIssueActivity;
export type TRecentProjectsWidgetResponse = string[];
export type TRecentCollaboratorsWidgetResponse = {
active_issue_count: number;
user_id: string;
};
export type TWidgetStatsResponse =
| TOverviewStatsWidgetResponse
| TIssuesByStateGroupsWidgetResponse[]
| TIssuesByPriorityWidgetResponse[]
| TAssignedIssuesWidgetResponse
| TCreatedIssuesWidgetResponse
| TRecentActivityWidgetResponse[]
| TRecentProjectsWidgetResponse
| TRecentCollaboratorsWidgetResponse[];
// dashboard
export type TDashboard = {
created_at: string;
created_by: string | null;
description_html: string;
id: string;
identifier: string | null;
is_default: boolean;
name: string;
owned_by: string;
type: string;
updated_at: string;
updated_by: string | null;
};
export type THomeDashboardResponse = {
dashboard: TDashboard;
widgets: TWidget[];
};
-1
View File
@@ -1,7 +1,6 @@
export * from "./users";
export * from "./workspace";
export * from "./cycles";
export * from "./dashboard";
export * from "./projects";
export * from "./state";
export * from "./invitation";
+1
View File
@@ -9,6 +9,7 @@ import type {
IStateLite,
Properties,
IIssueDisplayFilterOptions,
IIssueReaction,
TIssue,
} from "@plane/types";
+6 -1
View File
@@ -6,7 +6,12 @@ export type TIssueRelationTypes =
| "duplicate"
| "relates_to";
export type TIssueRelation = Record<TIssueRelationTypes, TIssue[]>;
export type TIssueRelationObject = { issue_detail: TIssue };
export type TIssueRelation = Record<
TIssueRelationTypes,
TIssueRelationObject[]
>;
export type TIssueRelationMap = {
[issue_id: string]: Record<TIssueRelationTypes, string[]>;
-4
View File
@@ -1,4 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
+9 -11
View File
@@ -17,17 +17,6 @@
"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",
@@ -40,5 +29,14 @@
"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"
}
}
-1
View File
@@ -141,7 +141,6 @@ export const Avatar: React.FC<Props> = (props) => {
}
: {}
}
tabIndex={-1}
>
{src ? (
<img src={src} className={`h-full w-full ${getBorderRadius(shape)} ${className}`} alt={name} />
+1 -2
View File
@@ -1,7 +1,6 @@
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;
@@ -32,7 +31,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) =>
const buttonIconStyle = getIconStyling(size);
return (
<button ref={ref} type={type} className={cn(buttonStyle, className)} disabled={disabled || loading} {...rest}>
<button ref={ref} type={type} className={`${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>}
+4 -4
View File
@@ -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`,
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`,
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`,
}
enum buttonIconStyling {
-1
View File
@@ -1,3 +1,2 @@
export * from "./button";
export * from "./helper";
export * from "./toggle-switch";
+5 -10
View File
@@ -11,7 +11,6 @@ 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 {
@@ -63,7 +62,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
static
>
<div
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 ${
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 ${
maxHeight === "lg"
? "max-h-60"
: maxHeight === "md"
@@ -73,7 +72,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
: maxHeight === "sm"
? "max-h-28"
: ""
} ${width === "auto" ? "min-w-[12rem] whitespace-nowrap" : width} ${optionsClassName}`}
} ${width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width} ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
@@ -168,13 +167,9 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
{({ active, close }) => (
<button
type="button"
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
)}
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}`}
onClick={(e) => {
close();
onClick && onClick(e);
+19 -63
View File
@@ -1,79 +1,35 @@
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 = "", containerClassName = "", size = 14, withContainer = false } = props;
const { priority, className = "", size = 14 } = props;
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",
// 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}`} />;
}
};
// 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
)}
/>
)}
</>
);
return <>{getPriorityIcon()}</>;
};
-2
View File
@@ -64,7 +64,6 @@
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 */
@@ -89,7 +88,6 @@
--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_GROUPS } from "constants/state";
import { STATE_GROUP_COLORS } 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_GROUPS[group.state_group as TStateGroups].color,
backgroundColor: STATE_GROUP_COLORS[group.state_group as TStateGroups],
}}
/>
<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_GROUPS[group.state_group as TStateGroups].color,
backgroundColor: STATE_GROUP_COLORS[group.state_group as TStateGroups],
}}
/>
</div>
@@ -1,10 +1,8 @@
import { useRouter } from "next/router";
import { Command } from "cmdk";
// hooks
import { useUser } from "hooks/store";
// icons
import { SettingIcon } from "components/icons";
import Link from "next/link";
// constants
import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "constants/workspace";
type Props = {
closePalette: () => void;
@@ -12,40 +10,60 @@ type Props = {
export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) => {
const { closePalette } = props;
// router
const router = useRouter();
const { workspaceSlug } = router.query;
// mobx store
const {
membership: { currentWorkspaceRole },
} = useUser();
// derived values
const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST;
const redirect = (path: string) => {
closePalette();
router.push(path);
};
return (
<>
{WORKSPACE_SETTINGS_LINKS.map(
(setting) =>
workspaceMemberInfo >= setting.access && (
<Command.Item
key={setting.key}
onSelect={() => redirect(`/${workspaceSlug}${setting.href}`)}
className="focus:outline-none"
>
<Link href={`/${workspaceSlug}${setting.href}`}>
<div className="flex items-center gap-2 text-custom-text-200">
<setting.Icon className="h-4 w-4 text-custom-text-200" />
{setting.label}
</div>
</Link>
</Command.Item>
)
)}
<Command.Item onSelect={closePalette} className="focus:outline-none">
<Link href={`/${workspaceSlug}/settings`}>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
General
</div>
</Link>
</Command.Item>
<Command.Item onSelect={closePalette} className="focus:outline-none">
<Link href={`/${workspaceSlug}/settings/members`}>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Members
</div>
</Link>
</Command.Item>
<Command.Item onSelect={closePalette} className="focus:outline-none">
<Link href={`/${workspaceSlug}/settings/billing`}>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Billing and Plans
</div>
</Link>
</Command.Item>
<Command.Item onSelect={closePalette} className="focus:outline-none">
<Link href={`/${workspaceSlug}/settings/integrations`}>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Integrations
</div>
</Link>
</Command.Item>
<Command.Item onSelect={closePalette} className="focus:outline-none">
<Link href={`/${workspaceSlug}/settings/imports`}>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Import
</div>
</Link>
</Command.Item>
<Command.Item onSelect={closePalette} className="focus:outline-none">
<Link href={`/${workspaceSlug}/settings/exports`}>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Export
</div>
</Link>
</Command.Item>
</>
);
};
@@ -60,7 +60,6 @@ export const CommandPalette: FC = observer(() => {
isDeleteIssueModalOpen,
toggleDeleteIssueModal,
isAnyModalOpen,
createIssueStoreType,
} = commandPalette;
const { setToastAlert } = useToast();
@@ -217,7 +216,6 @@ 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 && (
@@ -1,61 +0,0 @@
import { observer } from "mobx-react-lite";
// hooks
import { useApplication, useDashboard } from "hooks/store";
// components
import {
AssignedIssuesWidget,
CreatedIssuesWidget,
IssuesByPriorityWidget,
IssuesByStateGroupWidget,
OverviewStatsWidget,
RecentActivityWidget,
RecentCollaboratorsWidget,
RecentProjectsWidget,
WidgetProps,
} from "components/dashboard";
// types
import { TWidgetKeys } from "@plane/types";
const WIDGETS_LIST: {
[key in TWidgetKeys]: { component: React.FC<WidgetProps>; fullWidth: boolean };
} = {
overview_stats: { component: OverviewStatsWidget, fullWidth: true },
assigned_issues: { component: AssignedIssuesWidget, fullWidth: false },
created_issues: { component: CreatedIssuesWidget, fullWidth: false },
issues_by_state_groups: { component: IssuesByStateGroupWidget, fullWidth: false },
issues_by_priority: { component: IssuesByPriorityWidget, fullWidth: false },
recent_activity: { component: RecentActivityWidget, fullWidth: false },
recent_projects: { component: RecentProjectsWidget, fullWidth: false },
recent_collaborators: { component: RecentCollaboratorsWidget, fullWidth: true },
};
export const DashboardWidgets = observer(() => {
// store hooks
const {
router: { workspaceSlug },
} = useApplication();
const { homeDashboardId, homeDashboardWidgets } = useDashboard();
const doesWidgetExist = (widgetKey: TWidgetKeys) =>
Boolean(homeDashboardWidgets?.find((widget) => widget.key === widgetKey));
if (!workspaceSlug || !homeDashboardId) return null;
return (
<div className="grid lg:grid-cols-2 gap-7">
{Object.entries(WIDGETS_LIST).map(([key, widget]) => {
const WidgetComponent = widget.component;
// if the widget doesn't exist, return null
if (!doesWidgetExist(key as TWidgetKeys)) return null;
// if the widget is full width, return it in a 2 column grid
if (widget.fullWidth)
return (
<div className="col-span-2">
<WidgetComponent dashboardId={homeDashboardId} workspaceSlug={workspaceSlug} />
</div>
);
else return <WidgetComponent dashboardId={homeDashboardId} workspaceSlug={workspaceSlug} />;
})}
</div>
);
});
-3
View File
@@ -1,3 +0,0 @@
export * from "./widgets";
export * from "./home-dashboard-widgets";
export * from "./project-empty-state";
@@ -1,41 +0,0 @@
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>
);
});
@@ -1,119 +0,0 @@
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>
);
});
@@ -1,115 +0,0 @@
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>
);
});
@@ -1,41 +0,0 @@
import { ChevronDown } from "lucide-react";
// ui
import { CustomMenu } from "@plane/ui";
// types
import { TDurationFilterOptions } from "@plane/types";
// constants
import { DURATION_FILTER_OPTIONS } from "constants/dashboard";
type Props = {
onChange: (value: TDurationFilterOptions) => void;
value: TDurationFilterOptions;
};
export const DurationFilterDropdown: React.FC<Props> = (props) => {
const { onChange, value } = props;
return (
<CustomMenu
customButton={
<div className="px-3 py-2 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 focus:bg-custom-background-80 text-xs font-medium whitespace-nowrap rounded-md outline-none flex items-center gap-2">
{DURATION_FILTER_OPTIONS.find((option) => option.key === value)?.label}
<ChevronDown className="h-3 w-3" />
</div>
}
placement="bottom-end"
>
{DURATION_FILTER_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChange(option.key);
}}
>
{option.label}
</CustomMenu.MenuItem>
))}
</CustomMenu>
);
};
@@ -1 +0,0 @@
export * from "./duration-filter";
@@ -1,42 +0,0 @@
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>
);
};
@@ -1,42 +0,0 @@
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>
);
};
@@ -1,6 +0,0 @@
export * from "./assigned-issues";
export * from "./created-issues";
export * from "./issues-by-priority";
export * from "./issues-by-state-group";
export * from "./recent-activity";
export * from "./recent-collaborators";
@@ -1,45 +0,0 @@
import Image from "next/image";
import { useTheme } from "next-themes";
// assets
import DarkImage from "public/empty-state/dashboard/dark/issues-by-priority.svg";
import LightImage from "public/empty-state/dashboard/light/issues-by-priority.svg";
// 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>
);
};
@@ -1,45 +0,0 @@
import Image from "next/image";
import { useTheme } from "next-themes";
// assets
import DarkImage from "public/empty-state/dashboard/dark/issues-by-state-group.svg";
import LightImage from "public/empty-state/dashboard/light/issues-by-state-group.svg";
// 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>
);
};
@@ -1,42 +0,0 @@
import Image from "next/image";
import { useTheme } from "next-themes";
// assets
import DarkImage from "public/empty-state/dashboard/dark/recent-activity.svg";
import LightImage from "public/empty-state/dashboard/light/recent-activity.svg";
// 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>
);
};
@@ -1,40 +0,0 @@
import Image from "next/image";
import { useTheme } from "next-themes";
// assets
import DarkImage from "public/empty-state/dashboard/dark/recent-collaborators.svg";
import LightImage from "public/empty-state/dashboard/light/recent-collaborators.svg";
// helpers
import { cn } from "helpers/common.helper";
type Props = {};
export const RecentCollaboratorsEmptyState: React.FC<Props> = (props) => {
const {} = props;
// next-themes
const { resolvedTheme } = useTheme();
return (
<div className="mt-7 px-7 flex justify-between gap-16">
<p className="text-sm font-medium text-custom-text-300">
People are excited to work with you, once they do you will find your frequent collaborators here.
</p>
<div
className={cn("w-3/5 h-1/3 p-1.5 pb-0 rounded-t-md flex-shrink-0 self-end", {
"border border-custom-border-200": resolvedTheme === "dark",
})}
style={{
background:
resolvedTheme === "light"
? "linear-gradient(135deg, rgba(235, 243, 255, 0.45) 3.57%, rgba(99, 161, 255, 0.24) 94.16%)"
: "",
}}
>
<Image
src={resolvedTheme === "dark" ? DarkImage : LightImage}
className="w-full h-full"
alt="Recent collaborators"
/>
</div>
</div>
);
};
-12
View File
@@ -1,12 +0,0 @@
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";
@@ -1,3 +0,0 @@
export * from "./issue-list-item";
export * from "./issues-list";
export * from "./tabs-list";
@@ -1,297 +0,0 @@
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>
);
});
@@ -1,124 +0,0 @@
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>
)}
</>
);
};
@@ -1,26 +0,0 @@
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>
);
@@ -1,208 +0,0 @@
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>
);
});
@@ -1,188 +0,0 @@
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>
);
});
@@ -1,22 +0,0 @@
// ui
import { Loader } from "@plane/ui";
export const AssignedIssuesWidgetLoader = () => (
<Loader className="bg-custom-background-100 p-6 rounded-xl">
<div className="flex items-center justify-between gap-2">
<Loader.Item height="17px" width="35%" />
<Loader.Item height="17px" width="10%" />
</div>
<div className="mt-6 space-y-7">
<Loader.Item height="29px" />
<Loader.Item height="17px" width="10%" />
</div>
<div className="mt-11 space-y-10">
<Loader.Item height="11px" width="35%" />
<Loader.Item height="11px" width="45%" />
<Loader.Item height="11px" width="55%" />
<Loader.Item height="11px" width="40%" />
<Loader.Item height="11px" width="60%" />
</div>
</Loader>
);
@@ -1 +0,0 @@
export * from "./loader";
@@ -1,15 +0,0 @@
// ui
import { Loader } from "@plane/ui";
export const IssuesByPriorityWidgetLoader = () => (
<Loader className="bg-custom-background-100 rounded-xl p-6">
<Loader.Item height="17px" width="35%" />
<div className="flex items-center gap-1 h-full">
<Loader.Item height="119px" width="14%" />
<Loader.Item height="119px" width="26%" />
<Loader.Item height="119px" width="36%" />
<Loader.Item height="119px" width="18%" />
<Loader.Item height="119px" width="6%" />
</div>
</Loader>
);
@@ -1,21 +0,0 @@
// 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>
);
@@ -1,31 +0,0 @@
// 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];
};
@@ -1,13 +0,0 @@
// 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>
);
@@ -1,19 +0,0 @@
// 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>
);
@@ -1,18 +0,0 @@
// 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>
);
@@ -1,19 +0,0 @@
// 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>
);
@@ -1,93 +0,0 @@
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>
);
});
@@ -1,105 +0,0 @@
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>
);
});
@@ -1,93 +0,0 @@
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>
);
});
@@ -1,125 +0,0 @@
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>
);
});
+1 -1
View File
@@ -258,7 +258,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
>
<PriorityIcon
priority={priority.key}
size={14}
size={12}
className={cn({
"text-white": priority.key === "urgent" && highlightUrgent,
// centre align the icons if text is hidden
+2 -9
View File
@@ -12,12 +12,7 @@ import { Breadcrumbs, Button, LayersIcon, PhotoFilterIcon, Tooltip } from "@plan
// icons
import { List, PlusIcon, Sheet } from "lucide-react";
// types
import {
IIssueDisplayFilterOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
TStaticViewTypes,
} from "@plane/types";
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
import { EUserWorkspaceRoles } from "constants/workspace";
@@ -40,7 +35,7 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => {
const { workspaceSlug, globalViewId } = router.query;
// store hooks
const {
issuesFilter: { filters, updateFilters },
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.GLOBAL);
const {
membership: { currentWorkspaceRole },
@@ -52,8 +47,6 @@ 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;
+13 -2
View File
@@ -1,17 +1,23 @@
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, usePage, useProject } from "hooks/store";
import { useApplication, useProject } from "hooks/store";
// services
import { PageService } from "services/page.service";
// 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;
@@ -22,7 +28,12 @@ export const PageDetailsHeader: FC<IPagesHeaderProps> = observer((props) => {
const { commandPalette: commandPaletteStore } = useApplication();
const { currentProjectDetails } = useProject();
const pageDetails = usePage(pageId as string);
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
);
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">
@@ -19,7 +19,13 @@ export const WorkspaceActiveCycleHeader = observer(() => {
label="Active Cycles"
/>
</Breadcrumbs>
<span className="flex items-center justify-center px-3.5 py-0.5 text-xs leading-4 rounded-xl text-orange-500 bg-orange-500/20">
<span
className="flex items-center justify-center px-3.5 py-0.5 text-xs leading-5 rounded-xl"
style={{
color: "#F59E0B",
backgroundColor: "#F59E0B20",
}}
>
Beta
</span>
</div>
@@ -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_GROUPS } from "constants/state";
import { STATE_GROUP_COLORS } 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_GROUPS["backlog"].color}
color={color ?? STATE_GROUP_COLORS["backlog"]}
className={`flex-shrink-0 ${className}`}
/>
);
@@ -40,7 +40,7 @@ export const StateGroupIcon: React.FC<Props> = ({
<StateGroupCancelledIcon
width={width}
height={height}
color={color ?? STATE_GROUPS["cancelled"].color}
color={color ?? STATE_GROUP_COLORS["cancelled"]}
className={`flex-shrink-0 ${className}`}
/>
);
@@ -49,7 +49,7 @@ export const StateGroupIcon: React.FC<Props> = ({
<StateGroupCompletedIcon
width={width}
height={height}
color={color ?? STATE_GROUPS["completed"].color}
color={color ?? STATE_GROUP_COLORS["completed"]}
className={`flex-shrink-0 ${className}`}
/>
);
@@ -58,7 +58,7 @@ export const StateGroupIcon: React.FC<Props> = ({
<StateGroupStartedIcon
width={width}
height={height}
color={color ?? STATE_GROUPS["started"].color}
color={color ?? STATE_GROUP_COLORS["started"]}
className={`flex-shrink-0 ${className}`}
/>
);
@@ -67,7 +67,7 @@ export const StateGroupIcon: React.FC<Props> = ({
<StateGroupUnstartedIcon
width={width}
height={height}
color={color ?? STATE_GROUPS["unstarted"].color}
color={color ?? STATE_GROUP_COLORS["unstarted"]}
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, EIssuesStoreType.MODULE);
toggleCreateIssueModal(true);
},
}}
secondaryButton={
@@ -25,14 +25,13 @@ 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, alwaysAllowEditing } = props;
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, labels, states } = props;
// store hooks
const {
membership: { currentProjectRole },
@@ -42,7 +41,7 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
if (Object.keys(appliedFilters).length === 0) return null;
const isEditingAllowed = alwaysAllowEditing || (currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER);
const isEditingAllowed = currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
return (
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">

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