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