Compare commits

...

18 Commits

Author SHA1 Message Date
Palanikannan1437 3865bbdd30 feat: Add support for hardBreak extension
This commit adds support for the hardBreak extension in the `CoreEditorExtensions` component located at `packages/editor/core/src/ui/extensions/index.tsx`. The extension now includes an HTMLAttributes object with a class of "p-2" for styling purposes.
2024-01-19 16:34:28 +05:30
sriram veeraghanta e54b940f86 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-01-19 16:17:21 +05:30
Nikhil 543636eb40 dev: update apiserver .env.example (#3412) 2024-01-19 16:13:22 +05:30
Facundo Martin Gordillo 6c2fecd322 fix: Updated "deployment documentation" link (#3413) 2024-01-19 16:10:41 +05:30
Prateek Shourya e4084fe156 refactor: move all sidebar links to constant file. (#3414)
* chore: move all workspace, project and profile links constants into their own constants file.

* chore: sidebar menu links improvement.
2024-01-19 16:05:52 +05:30
Henit Chobisa e975abff21 Improvement: High Performance MobX Integration for Pages ✈︎ (#3397)
* fix: removed parameters `workspace`, `project` & `id` from the patch calls

* feat: modified components to work with new pages hooks

* feat: modified stores

* feat: modified initial component

* feat: component implementation changes

* feat: store implementation

* refactor pages store

* feat: updated page store to perform async operations faster

* fix: added types for archive and restore pages

* feat: implemented archive and restore pages

* fix: page creating twice when form submit

* feat: updated create-page-modal

* feat: updated page form and delete page modal

* fix: create page modal not updating isSubmitted prop

* feat: list items and list view refactored for pages

* feat: refactored project-page-store for inserting computed pagesids

* chore: renamed project pages hook

* feat: added favourite pages implementation

* fix: implemented store for archived pages

* fix: project page store for recent pages

* fix: issue suggestions breaking pages

* fix: issue embeds and suggestions breaking

* feat: implemented page store and project page store in page editor

* chore: lock file changes

* fix: modified page details header to catch mobx updates instead of swr calls

* fix: modified usePage hook to fetch page details when reloaded directly on page

* fix: fixed deleting pages

* fix: removed render on props changed

* feat: implemented page store inside page details

* fix: role change in pages archives

* fix: rerending of pages on tab change

* fix: reimplementation of peek overview inside pages

* chore: typo fixes

* fix: issue suggestion widget selecting wrong issues on click

* feat: added labels in pages

* fix: deepsource errors fixed

* fix: build errors

* fix: review comments

* fix: removed swr hooks from the `usePage` store hook and refactored `issueEmbed` hook

* fix: resolved reviewed comments

---------

Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
2024-01-19 15:18:47 +05:30
Bavisetti Narayan f68e6023c3 fix: user profile issues (#3409) 2024-01-19 15:17:49 +05:30
rahulramesha 67414983da fix: update global issues filter and enable profile issues (#3410)
* fix all issues and fix profile issues

* minor comments update

* minor change nullish check logic

* update nullish check logic

---------

Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
2024-01-19 15:06:19 +05:30
Anmol Singh Bhatia 37ddc64b83 chore: issue filter loader improvement (#3406) 2024-01-18 17:26:13 +05:30
sriram veeraghanta 57c25c9a5a Merge branch 'preview' of github.com:makeplane/plane into develop 2024-01-18 16:05:31 +05:30
Nikhil af5057defa fix: key validation when magic sign in (#3403) 2024-01-18 16:04:04 +05:30
dependabot[bot] c5e7c2f6a8 chore(deps): bump follow-redirects from 1.15.3 to 1.15.4 (#3349)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.3 to 1.15.4.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.3...v1.15.4)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-18 15:52:53 +05:30
rahulramesha c593d5df1b fix: enable global/ all issues (#3405)
* fix global issues and views

* remove separate layouts for specific views

* add permissions to views

* fix global issues filters

---------

Co-authored-by: Rahul R <rahulr@Rahuls-MacBook-Pro.local>
2024-01-18 15:51:17 +05:30
Bavisetti Narayan 9065b5d368 feat: dashboard widgets (#3362)
* fix: created dashboard, widgets and dashboard widget model

* fix: new user home dashboard

* chore: recent projects list

* chore: recent collaborators

* chore: priority order change

* chore: payload changes

* chore: collaborator's active issue count

* chore: all dashboard widgets added with services and typs

* chore: centered metric for pie chart

* chore: widget filters

* chore: created issue filter

* fix: created and assigned issues payload change

* chore: created issue payload change

* fix: date filter change

* chore: implement filters

* fix: added expansion fields

* fix: changed issue structure with relation

* chore: new issues response

* fix: project member fix

* chore: updated issue_relation structure

* chore: code cleanup

* chore: update issues response and added empty states

* fix: button text wrap

* chore: update empty state messages

* fix: filters

* chore: update dark mode empty states

* build-error: Type check in the issue relation service

* fix: issues redirection

* fix: project empty state

* chore: project member active check

* chore: project member check in state and priority

* chore: remove console logs and replace harcoded values with constants

* fix: code refactoring

* fix: key name changed

* refactor: mapping through similar components using an array

* fix: build errors

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-01-18 15:49:54 +05:30
Prateek Shourya a9e2e21641 refactor: update create/update issue modal to use currently active store's create/update method. (#3395)
* refactor: update `create/update issue` modal to use currently active store's create/update method.

* chore: add condition to avoid multiple API calls if the current store is MODULE or CYCLE.

* remove: console log

* chore: update `currentStore` to `storeType`.
2024-01-18 14:42:10 +05:30
sriram veeraghanta e175d50ab7 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-01-18 13:47:16 +05:30
Nikhil eda1e46a2d chore: update issue template to auto assign and update labels (#3404) 2024-01-18 13:45:52 +05:30
M. Palanikannan 75b8e3350a 🐛 fix: Hide drag handle when cursor leaves the editor container (#3401)
This fix adds support for hiding the drag handle when the cursor leaves the editor container. It improves the user experience by providing a cleaner interface and removing unnecessary visual elements especially while scrolling.

- Add `hideDragHandle` prop to `EditorContainer` component in `editor-container.tsx`.
- Implement `onMouseLeave` event handler in `EditorContainer` to invoke `hideDragHandle` function.
- Update `DragAndDrop` extension in `drag-drop.tsx` to accept a `setHideDragHandle` function as an optional parameter.
- Pass the `setHideDragHandle` function from `RichTextEditor` component to `DragAndDrop` extension in `RichTextEditorExtensions` function in `index.tsx`.
- Set `hideDragHandleOnMouseLeave` state in `RichTextEditor` component to store the `hideDragHandlerFromDragDrop` function.
- Create `setHideDragHandleFunction` callback function in `RichTextEditor` to update the `hideDragHandleOnMouseLeave` state.
- Pass `hideDragHandleOnMouseLeave` as `hideDragHandle` prop to `EditorContainer` component in `RichTextEditor`.
2024-01-18 12:43:43 +05:30
234 changed files with 8697 additions and 2618 deletions
+2 -1
View File
@@ -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:
+1 -1
View File
@@ -37,7 +37,7 @@ Meet [Plane](https://plane.so). An open-source software development tool to mana
> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases.
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/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
+5 -5
View File
@@ -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=""
+2 -1
View File
@@ -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
+6 -7
View File
@@ -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"
]
+16 -22
View File
@@ -293,31 +293,19 @@ class IssueLabelSerializer(BaseSerializer):
]
class IssueRelationLiteSerializer(DynamicBaseSerializer):
project_id = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta:
model = Issue
fields = [
"id",
"project_id",
"sequence_id",
]
read_only_fields = [
"workspace",
"project",
]
class IssueRelationSerializer(BaseSerializer):
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",
+2
View File
@@ -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,
+23
View File
@@ -0,0 +1,23 @@
from django.urls import path
from plane.app.views import DashboardEndpoint, WidgetsEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/dashboard/",
DashboardEndpoint.as_view(),
name="dashboard",
),
path(
"workspaces/<str:slug>/dashboard/<uuid:dashboard_id>/",
DashboardEndpoint.as_view(),
name="dashboard",
),
path(
"dashboard/<uuid:dashboard_id>/widgets/<uuid:widget_id>/",
WidgetsEndpoint.as_view(),
name="widgets",
),
]
+5
View File
@@ -177,3 +177,8 @@ from .webhook import (
WebhookLogsEndpoint,
WebhookSecretRegenerateEndpoint,
)
from .dashboard import (
DashboardEndpoint,
WidgetsEndpoint
)
+1 -1
View File
@@ -325,7 +325,7 @@ class MagicSignInEndpoint(BaseAPIView):
)
user_token = request.data.get("token", "").strip()
key = request.data.get("key", False).strip().lower()
key = request.data.get("key", "").strip().lower()
if not key or user_token == "":
return Response(
+658
View File
@@ -0,0 +1,658 @@
# Django imports
from django.db.models import (
Q,
Case,
When,
Value,
CharField,
Count,
F,
Exists,
OuterRef,
Max,
Subquery,
JSONField,
Func,
Prefetch,
)
from django.utils import timezone
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
# Module imports
from . import BaseAPIView
from plane.db.models import (
Issue,
IssueActivity,
ProjectMember,
Widget,
DashboardWidget,
Dashboard,
Project,
IssueLink,
IssueAttachment,
IssueRelation,
)
from plane.app.serializers import (
IssueActivitySerializer,
IssueSerializer,
DashboardSerializer,
WidgetSerializer,
)
from plane.utils.issue_filters import issue_filters
def dashboard_overview_stats(self, request, slug):
assigned_issues = Issue.issue_objects.filter(
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
).count()
pending_issues_count = Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
).count()
created_issues_count = Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
created_by_id=request.user.id,
).count()
completed_issues_count = Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
assignees__in=[request.user],
state__group="completed",
).count()
return Response(
{
"assigned_issues_count": assigned_issues,
"pending_issues_count": pending_issues_count,
"completed_issues_count": completed_issues_count,
"created_issues_count": created_issues_count,
},
status=status.HTTP_200_OK,
)
def dashboard_assigned_issues(self, request, slug):
filters = issue_filters(request.query_params, "GET")
issue_type = request.GET.get("issue_type", None)
# get all the assigned issues
assigned_issues = (
Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
assignees__in=[request.user],
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_relation",
queryset=IssueRelation.objects.select_related(
"related_issue"
).select_related("issue"),
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.order_by("created_at")
)
# Priority Ordering
priority_order = ["urgent", "high", "medium", "low", "none"]
assigned_issues = assigned_issues.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
if issue_type == "completed":
completed_issues_count = assigned_issues.filter(
state__group__in=["completed"]
).count()
completed_issues = assigned_issues.filter(
state__group__in=["completed"]
)[:5]
return Response(
{
"issues": IssueSerializer(
completed_issues, many=True, expand=self.expand
).data,
"count": completed_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "overdue":
overdue_issues_count = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now()
).count()
overdue_issues = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now()
)[:5]
return Response(
{
"issues": IssueSerializer(
overdue_issues, many=True, expand=self.expand
).data,
"count": overdue_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "upcoming":
upcoming_issues_count = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now()
).count()
upcoming_issues = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now()
)[:5]
return Response(
{
"issues": IssueSerializer(
upcoming_issues, many=True, expand=self.expand
).data,
"count": upcoming_issues_count,
},
status=status.HTTP_200_OK,
)
return Response(
{"error": "Please specify a valid issue type"},
status=status.HTTP_400_BAD_REQUEST,
)
def dashboard_created_issues(self, request, slug):
filters = issue_filters(request.query_params, "GET")
issue_type = request.GET.get("issue_type", None)
# get all the assigned issues
created_issues = (
Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
created_by=request.user,
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.order_by("created_at")
)
# Priority Ordering
priority_order = ["urgent", "high", "medium", "low", "none"]
created_issues = created_issues.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
if issue_type == "completed":
completed_issues_count = created_issues.filter(
state__group__in=["completed"]
).count()
completed_issues = created_issues.filter(
state__group__in=["completed"]
)[:5]
return Response(
{
"issues": IssueSerializer(completed_issues, many=True).data,
"count": completed_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "overdue":
overdue_issues_count = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now()
).count()
overdue_issues = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now()
)[:5]
return Response(
{
"issues": IssueSerializer(overdue_issues, many=True).data,
"count": overdue_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "upcoming":
upcoming_issues_count = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now()
).count()
upcoming_issues = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now()
)[:5]
return Response(
{
"issues": IssueSerializer(upcoming_issues, many=True).data,
"count": upcoming_issues_count,
},
status=status.HTTP_200_OK,
)
return Response(
{"error": "Please specify a valid issue type"},
status=status.HTTP_400_BAD_REQUEST,
)
def dashboard_issues_by_state_groups(self, request, slug):
filters = issue_filters(request.query_params, "GET")
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
issues_by_state_groups = (
Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
assignees__in=[request.user],
)
.filter(**filters)
.values("state__group")
.annotate(count=Count("id"))
)
# default state
all_groups = {state: 0 for state in state_order}
# Update counts for existing groups
for entry in issues_by_state_groups:
all_groups[entry["state__group"]] = entry["count"]
# Prepare output including all groups with their counts
output_data = [
{"state": group, "count": count} for group, count in all_groups.items()
]
return Response(output_data, status=status.HTTP_200_OK)
def dashboard_issues_by_priority(self, request, slug):
filters = issue_filters(request.query_params, "GET")
priority_order = ["urgent", "high", "medium", "low", "none"]
issues_by_priority = (
Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
assignees__in=[request.user],
)
.filter(**filters)
.values("priority")
.annotate(count=Count("id"))
)
# default priority
all_groups = {priority: 0 for priority in priority_order}
# Update counts for existing groups
for entry in issues_by_priority:
all_groups[entry["priority"]] = entry["count"]
# Prepare output including all groups with their counts
output_data = [
{"priority": group, "count": count}
for group, count in all_groups.items()
]
return Response(output_data, status=status.HTTP_200_OK)
def dashboard_recent_activity(self, request, slug):
queryset = IssueActivity.objects.filter(
~Q(field__in=["comment", "vote", "reaction", "draft"]),
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
actor=request.user,
).select_related("actor", "workspace", "issue", "project")[:8]
return Response(
IssueActivitySerializer(queryset, many=True).data,
status=status.HTTP_200_OK,
)
def dashboard_recent_projects(self, request, slug):
project_ids = (
IssueActivity.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
actor=request.user,
)
.values_list("project_id", flat=True)
.distinct()
)
# Extract project IDs from the recent projects
unique_project_ids = set(project_id for project_id in project_ids)
# Fetch additional projects only if needed
if len(unique_project_ids) < 4:
additional_projects = Project.objects.filter(
project_projectmember__member=request.user,
project_projectmember__is_active=True,
workspace__slug=slug,
).exclude(id__in=unique_project_ids)
# Append additional project IDs to the existing list
unique_project_ids.update(additional_projects.values_list("id", flat=True))
return Response(
list(unique_project_ids)[:4],
status=status.HTTP_200_OK,
)
def dashboard_recent_collaborators(self, request, slug):
# Fetch all project IDs where the user belongs to
user_projects = Project.objects.filter(
project_projectmember__member=request.user,
project_projectmember__is_active=True,
workspace__slug=slug,
).values_list("id", flat=True)
# Fetch all users who have performed an activity in the projects where the user exists
users_with_activities = (
IssueActivity.objects.filter(
workspace__slug=slug,
project_id__in=user_projects,
)
.values("actor")
.exclude(actor=request.user)
.annotate(num_activities=Count("actor"))
.order_by("-num_activities")
)[:7]
# Get the count of active issues for each user in users_with_activities
users_with_active_issues = []
for user_activity in users_with_activities:
user_id = user_activity["actor"]
active_issue_count = Issue.objects.filter(
assignees__in=[user_id],
state__group__in=["unstarted", "started"],
).count()
users_with_active_issues.append(
{"user_id": user_id, "active_issue_count": active_issue_count}
)
# Insert the logged-in user's ID and their active issue count at the beginning
active_issue_count = Issue.objects.filter(
assignees__in=[request.user],
state__group__in=["unstarted", "started"],
).count()
if users_with_activities.count() < 7:
# Calculate the additional collaborators needed
additional_collaborators_needed = 7 - users_with_activities.count()
# Fetch additional collaborators from the project_member table
additional_collaborators = list(
set(
ProjectMember.objects.filter(
~Q(member=request.user),
project_id__in=user_projects,
workspace__slug=slug,
)
.exclude(
member__in=[
user["actor"] for user in users_with_activities
]
)
.values_list("member", flat=True)
)
)
additional_collaborators = additional_collaborators[
:additional_collaborators_needed
]
# Append additional collaborators to the list
for collaborator_id in additional_collaborators:
active_issue_count = Issue.objects.filter(
assignees__in=[collaborator_id],
state__group__in=["unstarted", "started"],
).count()
users_with_active_issues.append(
{
"user_id": str(collaborator_id),
"active_issue_count": active_issue_count,
}
)
users_with_active_issues.insert(
0,
{"user_id": request.user.id, "active_issue_count": active_issue_count},
)
return Response(users_with_active_issues, status=status.HTTP_200_OK)
class DashboardEndpoint(BaseAPIView):
def create(self, request, slug):
serializer = DashboardSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, slug, pk):
serializer = DashboardSerializer(data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, pk):
serializer = DashboardSerializer(data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug, dashboard_id=None):
if not dashboard_id:
dashboard_type = request.GET.get("dashboard_type", None)
if dashboard_type == "home":
dashboard, created = Dashboard.objects.get_or_create(
type_identifier=dashboard_type, owned_by=request.user, is_default=True
)
if created:
widgets_to_fetch = [
"overview_stats",
"assigned_issues",
"created_issues",
"issues_by_state_groups",
"issues_by_priority",
"recent_activity",
"recent_projects",
"recent_collaborators",
]
updated_dashboard_widgets = []
for widget_key in widgets_to_fetch:
widget = Widget.objects.filter(key=widget_key).values_list("id", flat=True)
if widget:
updated_dashboard_widgets.append(
DashboardWidget(
widget_id=widget,
dashboard_id=dashboard.id,
)
)
DashboardWidget.objects.bulk_create(
updated_dashboard_widgets, batch_size=100
)
widgets = (
Widget.objects.annotate(
is_visible=Exists(
DashboardWidget.objects.filter(
widget_id=OuterRef("pk"),
dashboard_id=dashboard.id,
is_visible=True,
)
)
)
.annotate(
dashboard_filters=Subquery(
DashboardWidget.objects.filter(
widget_id=OuterRef("pk"),
dashboard_id=dashboard.id,
filters__isnull=False,
)
.exclude(filters={})
.values("filters")[:1]
)
)
.annotate(
widget_filters=Case(
When(
dashboard_filters__isnull=False,
then=F("dashboard_filters"),
),
default=F("filters"),
output_field=JSONField(),
)
)
)
return Response(
{
"dashboard": DashboardSerializer(dashboard).data,
"widgets": WidgetSerializer(widgets, many=True).data,
},
status=status.HTTP_200_OK,
)
return Response(
{"error": "Please specify a valid dashboard type"},
status=status.HTTP_400_BAD_REQUEST,
)
widget_key = request.GET.get("widget_key", "overview_stats")
WIDGETS_MAPPER = {
"overview_stats": dashboard_overview_stats,
"assigned_issues": dashboard_assigned_issues,
"created_issues": dashboard_created_issues,
"issues_by_state_groups": dashboard_issues_by_state_groups,
"issues_by_priority": dashboard_issues_by_priority,
"recent_activity": dashboard_recent_activity,
"recent_projects": dashboard_recent_projects,
"recent_collaborators": dashboard_recent_collaborators,
}
func = WIDGETS_MAPPER.get(widget_key)
if func is not None:
response = func(
self,
request=request,
slug=slug,
)
if isinstance(response, Response):
return response
return Response(
{"error": "Please specify a valid widget key"},
status=status.HTTP_400_BAD_REQUEST,
)
class WidgetsEndpoint(BaseAPIView):
def patch(self, request, dashboard_id, widget_id):
dashboard_widget = DashboardWidget.objects.filter(
widget_id=widget_id,
dashboard_id=dashboard_id,
).first()
dashboard_widget.is_visible = request.data.get(
"is_visible", dashboard_widget.is_visible
)
dashboard_widget.sort_order = request.data.get(
"sort_order", dashboard_widget.sort_order
)
dashboard_widget.filters = request.data.get(
"filters", dashboard_widget.filters
)
dashboard_widget.save()
return Response(
{"message": "successfully updated"}, status=status.HTTP_200_OK
)
-1
View File
@@ -52,7 +52,6 @@ from plane.app.serializers import (
IssueRelationSerializer,
RelatedIssueSerializer,
IssuePublicSerializer,
IssueRelationLiteSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
+10 -21
View File
@@ -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
):
+12 -18
View File
@@ -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),
]
+2
View File
@@ -90,3 +90,5 @@ from .notification import Notification
from .exporter import ExporterHistory
from .webhook import Webhook, WebhookLog
from .dashboard import Dashboard, DashboardWidget, Widget
+89
View File
@@ -0,0 +1,89 @@
import uuid
# Django imports
from django.db import models
from django.conf import settings
# Module imports
from . import BaseModel
from ..mixins import TimeAuditModel
class Dashboard(BaseModel):
DASHBOARD_CHOICES = (
("workspace", "Workspace"),
("project", "Project"),
("home", "Home"),
("team", "Team"),
("user", "User"),
)
name = models.CharField(max_length=255)
description_html = models.TextField(blank=True, default="<p></p>")
identifier = models.UUIDField(null=True)
owned_by = models.ForeignKey(
"db.User",
on_delete=models.CASCADE,
related_name="dashboards",
)
is_default = models.BooleanField(default=False)
type_identifier = models.CharField(
max_length=30,
choices=DASHBOARD_CHOICES,
verbose_name="Dashboard Type",
default="home",
)
def __str__(self):
"""Return name of the dashboard"""
return f"{self.name}"
class Meta:
verbose_name = "Dashboard"
verbose_name_plural = "Dashboards"
db_table = "dashboards"
ordering = ("-created_at",)
class Widget(TimeAuditModel):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
)
key = models.CharField(max_length=255)
filters = models.JSONField(default=dict)
def __str__(self):
"""Return name of the widget"""
return f"{self.key}"
class Meta:
verbose_name = "Widget"
verbose_name_plural = "Widgets"
db_table = "widgets"
ordering = ("-created_at",)
class DashboardWidget(BaseModel):
widget = models.ForeignKey(
Widget,
on_delete=models.CASCADE,
related_name="dashboard_widgets",
)
dashboard = models.ForeignKey(
Dashboard,
on_delete=models.CASCADE,
related_name="dashboard_widgets",
)
is_visible = models.BooleanField(default=True)
sort_order = models.FloatField(default=65535)
filters = models.JSONField(default=dict)
properties = models.JSONField(default=dict)
def __str__(self):
"""Return name of the dashboard"""
return f"{self.dashboard.name} {self.widget.key}"
class Meta:
unique_together = ("widget", "dashboard")
verbose_name = "Dashboard Widget"
verbose_name_plural = "Dashboard Widgets"
db_table = "dashboard_widgets"
ordering = ("-created_at",)
+11 -13
View File
@@ -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():
+1 -2
View File
@@ -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();
},
@@ -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()
@@ -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,
}),
];
@@ -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 &&
@@ -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"),
},
},
}),
+175
View File
@@ -0,0 +1,175 @@
import { IIssueActivity, TIssuePriorities } from "./issues";
import { TIssue } from "./issues/issue";
import { TIssueRelationTypes } from "./issues/issue_relation";
import { TStateGroups } from "./state";
export type TWidgetKeys =
| "overview_stats"
| "assigned_issues"
| "created_issues"
| "issues_by_state_groups"
| "issues_by_priority"
| "recent_activity"
| "recent_projects"
| "recent_collaborators";
export type TIssuesListTypes = "upcoming" | "overdue" | "completed";
export type TDurationFilterOptions =
| "today"
| "this_week"
| "this_month"
| "this_year";
// widget filters
export type TAssignedIssuesWidgetFilters = {
target_date?: TDurationFilterOptions;
tab?: TIssuesListTypes;
};
export type TCreatedIssuesWidgetFilters = {
target_date?: TDurationFilterOptions;
tab?: TIssuesListTypes;
};
export type TIssuesByStateGroupsWidgetFilters = {
target_date?: TDurationFilterOptions;
};
export type TIssuesByPriorityWidgetFilters = {
target_date?: TDurationFilterOptions;
};
export type TWidgetFiltersFormData =
| {
widgetKey: "assigned_issues";
filters: Partial<TAssignedIssuesWidgetFilters>;
}
| {
widgetKey: "created_issues";
filters: Partial<TCreatedIssuesWidgetFilters>;
}
| {
widgetKey: "issues_by_state_groups";
filters: Partial<TIssuesByStateGroupsWidgetFilters>;
}
| {
widgetKey: "issues_by_priority";
filters: Partial<TIssuesByPriorityWidgetFilters>;
};
export type TWidget = {
id: string;
is_visible: boolean;
key: TWidgetKeys;
readonly widget_filters: // only for read
TAssignedIssuesWidgetFilters &
TCreatedIssuesWidgetFilters &
TIssuesByStateGroupsWidgetFilters &
TIssuesByPriorityWidgetFilters;
filters: // only for write
TAssignedIssuesWidgetFilters &
TCreatedIssuesWidgetFilters &
TIssuesByStateGroupsWidgetFilters &
TIssuesByPriorityWidgetFilters;
};
export type TWidgetStatsRequestParams =
| {
widget_key: TWidgetKeys;
}
| {
target_date: string;
issue_type: TIssuesListTypes;
widget_key: "assigned_issues";
expand?: "issue_relation";
}
| {
target_date: string;
issue_type: TIssuesListTypes;
widget_key: "created_issues";
}
| {
target_date: string;
widget_key: "issues_by_state_groups";
}
| {
target_date: string;
widget_key: "issues_by_priority";
};
export type TWidgetIssue = TIssue & {
issue_relation: {
id: string;
project_id: string;
relation_type: TIssueRelationTypes;
sequence_id: number;
}[];
};
// widget stats responses
export type TOverviewStatsWidgetResponse = {
assigned_issues_count: number;
completed_issues_count: number;
created_issues_count: number;
pending_issues_count: number;
};
export type TAssignedIssuesWidgetResponse = {
issues: TWidgetIssue[];
count: number;
};
export type TCreatedIssuesWidgetResponse = {
issues: TWidgetIssue[];
count: number;
};
export type TIssuesByStateGroupsWidgetResponse = {
count: number;
state: TStateGroups;
};
export type TIssuesByPriorityWidgetResponse = {
count: number;
priority: TIssuePriorities;
};
export type TRecentActivityWidgetResponse = IIssueActivity;
export type TRecentProjectsWidgetResponse = string[];
export type TRecentCollaboratorsWidgetResponse = {
active_issue_count: number;
user_id: string;
};
export type TWidgetStatsResponse =
| TOverviewStatsWidgetResponse
| TIssuesByStateGroupsWidgetResponse[]
| TIssuesByPriorityWidgetResponse[]
| TAssignedIssuesWidgetResponse
| TCreatedIssuesWidgetResponse
| TRecentActivityWidgetResponse[]
| TRecentProjectsWidgetResponse
| TRecentCollaboratorsWidgetResponse[];
// dashboard
export type TDashboard = {
created_at: string;
created_by: string | null;
description_html: string;
id: string;
identifier: string | null;
is_default: boolean;
name: string;
owned_by: string;
type: string;
updated_at: string;
updated_by: string | null;
};
export type THomeDashboardResponse = {
dashboard: TDashboard;
widgets: TWidget[];
};
+1
View File
@@ -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";
-1
View File
@@ -9,7 +9,6 @@ import type {
IStateLite,
Properties,
IIssueDisplayFilterOptions,
IIssueReaction,
TIssue,
} from "@plane/types";
+1 -6
View File
@@ -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[]>;
+4
View File
@@ -0,0 +1,4 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
+11 -9
View File
@@ -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"
}
}
+1
View File
@@ -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} />
+2 -1
View File
@@ -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>}
+4 -4
View File
@@ -22,10 +22,10 @@ export interface IButtonStyling {
}
enum buttonSizeStyling {
sm = `px-3 py-1.5 font-medium text-xs rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center 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
View File
@@ -1,2 +1,3 @@
export * from "./button";
export * from "./helper";
export * from "./toggle-switch";
+10 -5
View File
@@ -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);
+63 -19
View File
@@ -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
)}
/>
)}
</>
);
};
+2
View File
@@ -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>
);
});
+3
View File
@@ -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>
);
};
+12
View File
@@ -0,0 +1,12 @@
export * from "./dropdowns";
export * from "./empty-states";
export * from "./issue-panels";
export * from "./loaders";
export * from "./assigned-issues";
export * from "./created-issues";
export * from "./issues-by-priority";
export * from "./issues-by-state-group";
export * from "./overview-stats";
export * from "./recent-activity";
export * from "./recent-collaborators";
export * from "./recent-projects";
@@ -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>
);
});
+1 -1
View File
@@ -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
+9 -2
View File
@@ -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;
+2 -13
View File
@@ -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;
@@ -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>
);
});
@@ -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