Compare commits

..

60 Commits

Author SHA1 Message Date
Dakshesh Jain b9975dfa24 refactor: typed context and using observer hoc 2023-08-11 14:05:32 +05:30
Dakshesh Jain 380ad340a6 dev: label state management setup 2023-08-10 17:51:47 +05:30
Dakshesh Jain f562fcd466 refactor: both delete or backspace can be used to open modal (#1829)
* refactor: both delete or backspace can be used to open modal

* fix: 'Bulk Issue Delete' modal will open on single click
2023-08-10 16:02:10 +05:30
Dakshesh Jain 8c8668a3e6 refactor: not making calls to member's endpoint to get membership status (#1831)
* refactor: not making call to members endpoint to get membership status

* refactor: type for IProject
2023-08-10 15:39:32 +05:30
Dakshesh Jain 9ce85cdf21 refactor: if/else conditions (#1822)
* refactor: shortcuts if/else

* fix: merge conflict
2023-08-10 13:12:17 +05:30
Dakshesh Jain 1c6cdb8328 refactor: added validation for display_name (#1823) 2023-08-10 13:05:03 +05:30
guru_sainath 005b42cb8d dev: Updating themening worfkflow (#1827) 2023-08-10 13:03:42 +05:30
Dakshesh Jain be062ccd34 fix: import user using endpoint with email (#1819) 2023-08-09 17:49:25 +05:30
Dakshesh Jain c9d0c5353d style: displaying email in members page depending on user role (#1817)
* style: displaying email in members page depending on user role

* refactor: added null safety
2023-08-09 17:23:57 +05:30
Dakshesh Jain 2a6eb5fe23 style: option to edit display name in profile page (#1818) 2023-08-09 16:44:35 +05:30
Aaryan Khandelwal d9ccce41bc chore: profile and my issues page start date option (#1815)
* chore: profile and my issues page start date option

* fix: properties order in my profile view options
2023-08-09 15:46:47 +05:30
Anmol Singh Bhatia 3db69a3a71 style: profile empty state (#1811)
* style: profile empty state

* style: priority empty state padding
2023-08-09 15:45:20 +05:30
Dakshesh Jain faa50b0bbb fix: analytics showing user id instead of display name (#1812)
* fix: replacing first, last name and email with display name

* fix: different endpoint for workspace & project member

* fix: falling back to email if display_name doesn't exist

* fix: analytics showing user id instead of display name
2023-08-09 15:20:29 +05:30
Dakshesh Jain 1991e09035 refactor: removed escape keydown listener (#1814) 2023-08-09 15:18:26 +05:30
Aaryan Khandelwal 4fcd081d27 chore: select start date option for issue (#1813) 2023-08-09 15:17:32 +05:30
Aaryan Khandelwal 5f1209f1db chore: empty state for multi-level dropdown (#1802)
* fix :label filter should show something if there is no label #1779 (#1795)

* style: children empty state

---------

Co-authored-by: Pankaj Chotaliya <34762752+pankajvc@users.noreply.github.com>
2023-08-08 14:13:26 +05:30
Aaryan Khandelwal 88e5a05253 chore: subscribed by me tab on my issues page (#1800)
* chore: add subscribed by me tab in my issues

* chore: update tab titles

* fix: build error
2023-08-08 13:15:25 +05:30
Dakshesh Jain 981acc81c1 fix: replaced first name, last name or email to display name (#1796)
* fix: replacing first, last name and email with display name

* fix: different endpoint for workspace & project member

* fix: falling back to email if display_name doesn't exist
2023-08-08 13:01:43 +05:30
Nikhil cf306ee605 feat: user display name (#1179)
* feat: user display name for the entire system

* feat: update issue activity to remove emails

* dev: update to display name wherever assignees__email and member__email

* dev: update display names on issue activity and the user script

* dev: update display_name function to generate display_name from email

* dev: add email for test purpose

* dev: set default display name for the user

* dev: add migration script and default value

* dev: annotate with assignees_id

* dev: return assignees id

* dev: display name for the profile

* dev: project members endpoint

* dev: url update

* dev: trailing /

* dev: update workspace member serializer

* fix: activity for assignees
2023-08-08 12:59:04 +05:30
guru_sainath 9df0ba6e3a feat: initiated plane space (#1801) 2023-08-08 12:55:42 +05:30
guru_sainath b6744dcd29 Chore: mobx setup and app sidebar and theme management (#1798)
* dev: Mobx integration for app sidebar and custom theme

* dev: Handled edge case and conditional rendering for mobx store
2023-08-08 12:50:27 +05:30
DevMiner a164dfd532 chore(frontend): add sharp (#1451) 2023-08-07 15:52:08 +05:30
Bavisetti Narayan 2b46e5f977 feat: issue export csv (#1781)
* feat: created issue export csv

* fix: optimized the queries

---------

Co-authored-by: NarayanBavisetti <narayan311@gmail.com>
2023-08-07 11:59:04 +05:30
Aaryan Khandelwal 97c3fb40e7 fix: custom theme persisting after signing out (#1780)
* fix: custom theme persistence

* chore: remove console logs

* fix: build error

* fix: change theme from command k
2023-08-03 15:01:31 +05:30
Dakshesh Jain 5aad6c71da fix: notification read status being toggled when click on link (#1769) 2023-08-02 17:23:55 +05:30
Aaryan Khandelwal a1ae338c37 chore: add non existing states for project entities (#1770) 2023-08-02 16:47:36 +05:30
Anmol Singh Bhatia c16b0daa22 style: profile activity (#1771)
* style: profile activity comment log styling

* chore: profile feed activity refactor

* style: sidebar project list
2023-08-02 16:45:34 +05:30
Nikhil 9a29896291 fix: bulk issue import (#1773) 2023-08-02 16:42:47 +05:30
Nikhil a66dcb9419 fix: user profiles n plus 1 (#1765) 2023-08-02 16:42:24 +05:30
Dakshesh Jain 87a920174e fix: comment reaction mutation (#1768) 2023-08-02 14:21:48 +05:30
Anmol Singh Bhatia 584192faba style: sidebar project list improvement (#1767) 2023-08-02 14:21:26 +05:30
Dakshesh Jain b61adbed4b fix: invalid project selection in create issue modal (#1766) 2023-08-02 13:38:45 +05:30
Aaryan Khandelwal 7434800999 chore: show message if dragging unjoined project (#1763) 2023-08-02 12:09:53 +05:30
Nikhil 78095e3823 Merge pull request #1760 from makeplane/fix/project_member_invite
fix: project invite
2023-08-01 22:05:48 +05:30
pablohashescobar f41086fd26 fix: typeerror 2023-08-01 22:00:56 +05:30
pablohashescobar 0866dc3494 fix: project invite 2023-08-01 21:42:48 +05:30
Nikhil 6ea15ced02 fix: project identifier length (#1757) 2023-08-01 19:25:04 +05:30
Nikhil 11525f26d0 fix: project identifier migration (#1755) 2023-08-01 19:11:32 +05:30
Anmol Singh Bhatia f3bd1691ce style: sidebar project list styling (#1756) 2023-08-01 19:11:09 +05:30
Anmol Singh Bhatia d83a76a3aa style: my issue and profile page view dropdown (#1754) 2023-08-01 19:04:53 +05:30
Aaryan Khandelwal 2cd431b4a4 fix: my issues mutation (#1753)
* fix: my issues mutation

* fix: activity message and icon return value
2023-08-01 18:40:04 +05:30
Aaryan Khandelwal d22e4b8212 fix: profile activity workspace slug (#1752) 2023-08-01 18:38:33 +05:30
Anmol Singh Bhatia 0e0e09c4fd style: profile dropdown updated (#1751) 2023-08-01 18:25:13 +05:30
Aaryan Khandelwal a8816ef473 refactor: issue activity component (#1749) 2023-08-01 17:07:11 +05:30
Anmol Singh Bhatia d315a24c1c style: primary color variable added in global (#1748) 2023-08-01 17:04:23 +05:30
Nikhil e73a4bef4e chore: issue and project details in activity (#1747)
* chore: issue and project details in activity

* dev: update capture log
2023-08-01 17:03:19 +05:30
Dakshesh Jain a66a0680df fix: showing alert on error while deleting workspace member or invited member (#1746)
style: showing 'Leave' for current user
2023-08-01 15:32:42 +05:30
Anmol Singh Bhatia 98c7453741 style: view dropdown (#1742) 2023-08-01 14:16:43 +05:30
Nikhil 1a5faca77c chore: show created by empty for viewers and guests (#1740)
* chore: show created by empty for viewers and guests

* dev: return empty queryset
2023-08-01 14:16:21 +05:30
Nikhil 6e7fa1a39c chore: project create to return sort order (#1738)
* chore: project create retun sort order

* chore: project create return sort order
2023-08-01 14:15:40 +05:30
Nikhil 7a6e742362 dev: fix migrations (#1735)
* dev: fix migrations

* dev: migrations for issue comment reactions and preference and cover image fields
2023-08-01 14:15:09 +05:30
Anmol Singh Bhatia 8a9ff31009 style: sidebar project disclosure list open by default (#1744) 2023-08-01 14:13:42 +05:30
Dakshesh Jain d310b8f86f style: style if user doesn't have profile pic (#1745) 2023-08-01 14:12:57 +05:30
Nikhil 4e297d92f3 chore: update empty states, fix: delete issue modal (#1743)
* chore: update empty states

* fix: delete issue modal

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-08-01 13:58:58 +05:30
Anmol Singh Bhatia e48147f87e style: create project modal (#1741) 2023-08-01 13:34:54 +05:30
Aaryan Khandelwal 85a7a7df2b chore: profile dropdown in the sidebar (#1737)
* chore: profile dropdown in the sidebar

* style: update spacing and font sizes
2023-08-01 13:31:16 +05:30
Dakshesh Jain 92b22dc99e style: showing 'Created by me' tab to all user (#1739)
* style: showing 'Created by me' tab to all user

* refactor: removed unnecessary imports
2023-08-01 13:30:51 +05:30
Aaryan Khandelwal cb4d294608 style: sidebar projects design (#1736)
* chore: disclosure menu for sidebar projects

* fix: projects list spacing

* style: new design
2023-08-01 12:24:34 +05:30
Aaryan Khandelwal df8504e6f7 fix: issue redirection (#1733) 2023-08-01 11:22:47 +05:30
Dakshesh Jain 7287c27b73 refactor: changed per_page to 30 (#1734) 2023-08-01 11:19:06 +05:30
200 changed files with 7140 additions and 4516 deletions
+4 -4
View File
@@ -1,4 +1,4 @@
import os, sys
import os, sys, random, string
import uuid
sys.path.append("/code")
@@ -19,9 +19,9 @@ def populate():
user = User.objects.create(email=default_email, username=uuid.uuid4().hex)
user.set_password(default_password)
user.save()
print("User created")
print("Success")
print(f"User created with an email: {default_email}")
else:
print(f"User already exists with the default email: {default_email}")
if __name__ == "__main__":
+2 -6
View File
@@ -1,10 +1,5 @@
from .base import BaseSerializer
from .people import (
ChangePasswordSerializer,
ResetPasswordSerializer,
TokenSerializer,
)
from .user import UserSerializer, UserLiteSerializer
from .user import UserSerializer, UserLiteSerializer, ChangePasswordSerializer, ResetPasswordSerializer, UserAdminLiteSerializer
from .workspace import (
WorkSpaceSerializer,
WorkSpaceMemberSerializer,
@@ -12,6 +7,7 @@ from .workspace import (
WorkSpaceMemberInviteSerializer,
WorkspaceLiteSerializer,
WorkspaceThemeSerializer,
WorkspaceMemberAdminSerializer,
)
from .project import (
ProjectSerializer,
+2 -1
View File
@@ -291,7 +291,8 @@ class IssueCreateSerializer(BaseSerializer):
class IssueActivitySerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = IssueActivity
-57
View File
@@ -1,57 +0,0 @@
from rest_framework.serializers import (
ModelSerializer,
Serializer,
CharField,
SerializerMethodField,
)
from rest_framework.authtoken.models import Token
from rest_framework_simplejwt.tokens import RefreshToken
from plane.db.models import User
class UserSerializer(ModelSerializer):
class Meta:
model = User
fields = "__all__"
extra_kwargs = {"password": {"write_only": True}}
class ChangePasswordSerializer(Serializer):
model = User
"""
Serializer for password change endpoint.
"""
old_password = CharField(required=True)
new_password = CharField(required=True)
class ResetPasswordSerializer(Serializer):
model = User
"""
Serializer for password change endpoint.
"""
new_password = CharField(required=True)
confirm_password = CharField(required=True)
class TokenSerializer(ModelSerializer):
user = UserSerializer()
access_token = SerializerMethodField()
refresh_token = SerializerMethodField()
def get_access_token(self, obj):
refresh_token = RefreshToken.for_user(obj.user)
return str(refresh_token.access_token)
def get_refresh_token(self, obj):
refresh_token = RefreshToken.for_user(obj.user)
return str(refresh_token)
class Meta:
model = Token
fields = "__all__"
+12 -1
View File
@@ -7,7 +7,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.api.serializers.workspace import WorkSpaceSerializer, WorkspaceLiteSerializer
from plane.api.serializers.user import UserLiteSerializer
from plane.api.serializers.user import UserLiteSerializer, UserAdminLiteSerializer
from plane.db.models import (
Project,
ProjectMember,
@@ -110,6 +110,17 @@ class ProjectMemberSerializer(BaseSerializer):
fields = "__all__"
class ProjectMemberAdminSerializer(BaseSerializer):
workspace = WorkspaceLiteSerializer(read_only=True)
project = ProjectLiteSerializer(read_only=True)
member = UserAdminLiteSerializer(read_only=True)
class Meta:
model = ProjectMember
fields = "__all__"
class ProjectMemberInviteSerializer(BaseSerializer):
project = ProjectLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
+43 -1
View File
@@ -1,3 +1,6 @@
# Third party imports
from rest_framework import serializers
# Module import
from .base import BaseSerializer
from plane.db.models import User
@@ -37,11 +40,50 @@ class UserLiteSerializer(BaseSerializer):
"id",
"first_name",
"last_name",
"email",
"avatar",
"is_bot",
"display_name",
]
read_only_fields = [
"id",
"is_bot",
]
class UserAdminLiteSerializer(BaseSerializer):
class Meta:
model = User
fields = [
"id",
"first_name",
"last_name",
"avatar",
"is_bot",
"display_name",
"email",
]
read_only_fields = [
"id",
"is_bot",
]
class ChangePasswordSerializer(serializers.Serializer):
model = User
"""
Serializer for password change endpoint.
"""
old_password = serializers.CharField(required=True)
new_password = serializers.CharField(required=True)
class ResetPasswordSerializer(serializers.Serializer):
model = User
"""
Serializer for password change endpoint.
"""
new_password = serializers.CharField(required=True)
confirm_password = serializers.CharField(required=True)
+22 -13
View File
@@ -3,7 +3,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .user import UserLiteSerializer, UserAdminLiteSerializer
from plane.db.models import (
User,
@@ -33,10 +33,30 @@ class WorkSpaceSerializer(BaseSerializer):
"owner",
]
class WorkspaceLiteSerializer(BaseSerializer):
class Meta:
model = Workspace
fields = [
"name",
"slug",
"id",
]
read_only_fields = fields
class WorkSpaceMemberSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True)
workspace = WorkSpaceSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
class Meta:
model = WorkspaceMember
fields = "__all__"
class WorkspaceMemberAdminSerializer(BaseSerializer):
member = UserAdminLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True)
class Meta:
model = WorkspaceMember
@@ -101,17 +121,6 @@ class TeamSerializer(BaseSerializer):
return super().update(instance, validated_data)
class WorkspaceLiteSerializer(BaseSerializer):
class Meta:
model = Workspace
fields = [
"name",
"slug",
"id",
]
read_only_fields = fields
class WorkspaceThemeSerializer(BaseSerializer):
class Meta:
model = WorkspaceTheme
+18
View File
@@ -32,6 +32,7 @@ from plane.api.views import (
InviteWorkspaceEndpoint,
JoinWorkspaceEndpoint,
WorkSpaceMemberViewSet,
WorkspaceMembersEndpoint,
WorkspaceInvitationsViewset,
UserWorkspaceInvitationsEndpoint,
WorkspaceMemberUserEndpoint,
@@ -59,6 +60,7 @@ from plane.api.views import (
ProjectViewSet,
InviteProjectEndpoint,
ProjectMemberViewSet,
ProjectMemberEndpoint,
ProjectMemberInvitationsViewset,
ProjectMemberUserEndpoint,
AddMemberToProjectEndpoint,
@@ -86,6 +88,7 @@ from plane.api.views import (
IssueSubscriberViewSet,
IssueReactionViewSet,
CommentReactionViewSet,
ExportIssuesEndpoint,
## End Issues
# States
StateViewSet,
@@ -334,6 +337,11 @@ urlpatterns = [
),
name="workspace",
),
path(
"workspaces/<str:slug>/workspace-members/",
WorkspaceMembersEndpoint.as_view(),
name="workspace-members",
),
path(
"workspaces/<str:slug>/teams/",
TeamMemberViewSet.as_view(
@@ -467,6 +475,11 @@ urlpatterns = [
),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/",
ProjectMemberEndpoint.as_view(),
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/add/",
AddMemberToProjectEndpoint.as_view(),
@@ -808,6 +821,11 @@ urlpatterns = [
IssueAttachmentEndpoint.as_view(),
name="project-issue-attachments",
),
path(
"workspaces/<str:slug>/export-issues/",
ExportIssuesEndpoint.as_view(),
name="export-issues",
),
## End Issues
## Issue Activity
path(
+4 -1
View File
@@ -12,8 +12,9 @@ from .project import (
ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint,
ProjectFavoritesViewSet,
ProjectMemberEndpoint,
)
from .people import (
from .user import (
UserEndpoint,
UpdateUserOnBoardedEndpoint,
UpdateUserTourCompletedEndpoint,
@@ -47,6 +48,7 @@ from .workspace import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceMembersEndpoint,
)
from .state import StateViewSet
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
@@ -75,6 +77,7 @@ from .issue import (
IssueSubscriberViewSet,
CommentReactionViewSet,
IssueReactionViewSet,
ExportIssuesEndpoint
)
from .auth_extended import (
+5 -5
View File
@@ -79,12 +79,12 @@ class AnalyticsEndpoint(BaseAPIView):
)
assignee_details = {}
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
if x_axis in ["assignees__id"] or segment in ["assignees__id"]:
assignee_details = (
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
.order_by("assignees__id")
.distinct("assignees__id")
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name", "assignees__id")
)
@@ -243,21 +243,21 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
)
most_issue_created_user = (
queryset.exclude(created_by=None)
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__email")
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__display_name")
.annotate(count=Count("id"))
.order_by("-count")
)[:5]
most_issue_closed_user = (
queryset.filter(completed_at__isnull=False, assignees__isnull=False)
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name")
.annotate(count=Count("id"))
.order_by("-count")
)[:5]
pending_issue_user = (
queryset.filter(completed_at__isnull=True)
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name")
.annotate(count=Count("id"))
.order_by("-count")
)
+1 -1
View File
@@ -22,7 +22,7 @@ from sentry_sdk import capture_exception
## Module imports
from . import BaseAPIView
from plane.api.serializers.people import (
from plane.api.serializers import (
ChangePasswordSerializer,
ResetPasswordSerializer,
)
+2 -2
View File
@@ -332,7 +332,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
# if there is no default state assign any random state
if default_state is None:
default_state = State.objects.filter(
~Q(name="Triage"), sproject_id=project_id
~Q(name="Triage"), project_id=project_id
).first()
# Get the maximum sequence_id
@@ -458,7 +458,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
actor=request.user,
project_id=project_id,
workspace_id=project.workspace_id,
comment=f"{request.user.email} importer the issue from {service}",
comment=f"imported the issue from {service}",
verb="created",
created_by=request.user,
)
+29 -1
View File
@@ -74,6 +74,7 @@ from plane.db.models import (
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.project_issue_export import issue_export_task
class IssueViewSet(BaseViewSet):
@@ -477,7 +478,7 @@ class IssueActivityEndpoint(BaseAPIView):
~Q(field="comment"),
project__project_projectmember__member=self.request.user,
)
.select_related("actor", "workspace")
.select_related("actor", "workspace", "issue", "project")
).order_by("created_at")
issue_comments = (
IssueComment.objects.filter(issue_id=issue_id)
@@ -1445,3 +1446,30 @@ class CommentReactionViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ExportIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def post(self, request, slug):
try:
issue_export_task.delay(
email=request.user.email, data=request.data, slug=slug ,exporter_name=request.user.first_name
)
return Response(
{
"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"
},
status=status.HTTP_200_OK,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+8 -5
View File
@@ -10,7 +10,7 @@ from plane.utils.paginator import BasePaginator
# Module imports
from .base import BaseViewSet, BaseAPIView
from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue
from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue, WorkspaceMember
from plane.api.serializers import NotificationSerializer
@@ -83,10 +83,13 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
# Created issues
if type == "created":
issue_ids = Issue.objects.filter(
workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
if WorkspaceMember.objects.filter(workspace__slug=slug, member=request.user, role__lt=15).exists():
notifications = Notification.objects.none()
else:
issue_ids = Issue.objects.filter(
workspace__slug=slug, created_by=request.user
).values_list("pk", flat=True)
notifications = notifications.filter(entity_identifier__in=issue_ids)
# Pagination
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
+1 -1
View File
@@ -301,7 +301,7 @@ class CreateIssueFromPageBlockEndpoint(BaseAPIView):
issue=issue,
actor=request.user,
project_id=project_id,
comment=f"{request.user.email} created the issue from {page_block.name} block",
comment=f"created the issue from {page_block.name} block",
verb="created",
)
+43 -11
View File
@@ -25,7 +25,7 @@ from plane.api.serializers import (
ProjectFavoriteSerializer,
)
from plane.api.permissions import ProjectBasePermission
from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission
from plane.db.models import (
Project,
@@ -123,7 +123,7 @@ class ProjectViewSet(BaseViewSet):
sort_order_query = ProjectMember.objects.filter(
member=request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
workspace__slug=self.kwargs.get("slug"),
).values("sort_order")
projects = (
self.get_queryset()
@@ -176,7 +176,7 @@ class ProjectViewSet(BaseViewSet):
serializer.save()
# Add the user as Administrator to the project
ProjectMember.objects.create(
project_member = ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20
)
@@ -238,9 +238,11 @@ class ProjectViewSet(BaseViewSet):
]
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
data = serializer.data
data["sort_order"] = project_member.sort_order
return Response(data, status=status.HTTP_201_CREATED)
return Response(
[serializer.errors[error][0] for error in serializer.errors],
serializer.errors,
status=status.HTTP_400_BAD_REQUEST,
)
except IntegrityError as e:
@@ -456,7 +458,7 @@ class ProjectMemberViewSet(BaseViewSet):
]
search_fields = [
"member__email",
"member__display_name",
"member__first_name",
]
@@ -600,19 +602,29 @@ class AddMemberToProjectEndpoint(BaseAPIView):
)
bulk_project_members = []
project_members = ProjectMember.objects.filter(
workspace=self.workspace, member_id__in=[member.get("member_id") for member in members]
).values("member_id").annotate(sort_order_min=Min("sort_order"))
project_members = (
ProjectMember.objects.filter(
workspace__slug=slug,
member_id__in=[member.get("member_id") for member in members],
)
.values("member_id", "sort_order")
.order_by("sort_order")
)
for member in members:
sort_order = [project_member.get("sort_order") for project_member in project_members]
sort_order = [
project_member.get("sort_order")
for project_member in project_members
if str(project_member.get("member_id"))
== str(member.get("member_id"))
]
bulk_project_members.append(
ProjectMember(
member_id=member.get("member_id"),
role=member.get("role", 10),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=sort_order[0] - 10000 if len(sort_order) else 65535
sort_order=sort_order[0] - 10000 if len(sort_order) else 65535,
)
)
@@ -972,3 +984,23 @@ class ProjectFavoritesViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectMemberEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
try:
project_members = ProjectMember.objects.filter(
project_id=project_id, workspace__slug=slug
).select_related("project", "member")
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -140,7 +140,7 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
def get(self, request):
try:
queryset = IssueActivity.objects.filter(actor=request.user).select_related(
"actor", "workspace"
"actor", "workspace", "issue", "project"
)
return self.paginate(
+45 -12
View File
@@ -47,6 +47,7 @@ from plane.api.serializers import (
WorkspaceThemeSerializer,
IssueActivitySerializer,
IssueLiteSerializer,
WorkspaceMemberAdminSerializer
)
from plane.api.views.base import BaseAPIView
from . import BaseViewSet
@@ -75,6 +76,7 @@ from plane.db.models import (
Label,
WorkspaceMember,
CycleIssue,
IssueReaction,
)
from plane.api.permissions import (
WorkSpaceBasePermission,
@@ -536,7 +538,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
class WorkSpaceMemberViewSet(BaseViewSet):
serializer_class = WorkSpaceMemberSerializer
serializer_class = WorkspaceMemberAdminSerializer
model = WorkspaceMember
permission_classes = [
@@ -544,7 +546,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
]
search_fields = [
"member__email",
"member__display_name",
"member__first_name",
]
@@ -689,7 +691,7 @@ class TeamMemberViewSet(BaseViewSet):
]
search_fields = [
"member__email",
"member__display_name",
"member__first_name",
]
@@ -1047,7 +1049,6 @@ class WorkspaceThemeViewSet(BaseViewSet):
class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
filters = issue_filters(request.query_params, "GET")
@@ -1145,14 +1146,18 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
upcoming_cycles = CycleIssue.objects.filter(
workspace__slug=slug,
cycle__start_date__gt=timezone.now().date(),
issue__assignees__in=[user_id,]
issue__assignees__in=[
user_id,
],
).values("cycle__name", "cycle__id", "cycle__project_id")
present_cycle = CycleIssue.objects.filter(
workspace__slug=slug,
cycle__start_date__lt=timezone.now().date(),
cycle__end_date__gt=timezone.now().date(),
issue__assignees__in=[user_id,]
issue__assignees__in=[
user_id,
],
).values("cycle__name", "cycle__id", "cycle__project_id")
return Response(
@@ -1165,7 +1170,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
"pending_issues": pending_issues_count,
"subscribed_issues": subscribed_issues_count,
"present_cycles": present_cycle,
"upcoming_cycles": upcoming_cycles,
"upcoming_cycles": upcoming_cycles,
}
)
except Exception as e:
@@ -1183,14 +1188,13 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
projects = request.query_params.getlist("project", [])
queryset = IssueActivity.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
actor=user_id,
).select_related("actor", "workspace")
).select_related("actor", "workspace", "issue", "project")
if projects:
queryset = queryset.filter(project__in=projects)
@@ -1211,12 +1215,13 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
class WorkspaceUserProfileEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
try:
user_data = User.objects.get(pk=user_id)
requesting_workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user)
requesting_workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug, member=request.user
)
projects = []
if requesting_workspace_member.role >= 10:
projects = (
@@ -1226,7 +1231,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
)
.annotate(
created_issues=Count(
"project_issue", filter=Q(project_issue__created_by_id=user_id)
"project_issue",
filter=Q(project_issue__created_by_id=user_id),
)
)
.annotate(
@@ -1281,6 +1287,7 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
"cover_image": user_data.cover_image,
"date_joined": user_data.date_joined,
"user_timezone": user_data.user_timezone,
"display_name": user_data.display_name,
},
},
status=status.HTTP_200_OK,
@@ -1321,6 +1328,12 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.order_by("-created_at")
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
@@ -1432,3 +1445,23 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceMembersEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
try:
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug
).select_related("workspace", "member")
serialzier = WorkSpaceMemberSerializer(workspace_members, many=True)
return Response(serialzier.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
+10 -10
View File
@@ -21,7 +21,7 @@ row_mapping = {
"state__name": "State",
"state__group": "State Group",
"labels__name": "Label",
"assignees__email": "Assignee Name",
"assignees__display_name": "Assignee Name",
"start_date": "Start Date",
"target_date": "Due Date",
"completed_at": "Completed At",
@@ -51,12 +51,12 @@ def analytic_export_task(email, data, slug):
segmented = segment
assignee_details = {}
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
if x_axis in ["assignees__display_name"] or segment in ["assignees__display_name"]:
assignee_details = (
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
.order_by("assignees__id")
.distinct("assignees__id")
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name")
)
if segment:
@@ -93,17 +93,17 @@ def analytic_export_task(email, data, slug):
else:
generated_row.append("0")
# x-axis replacement for names
if x_axis in ["assignees__email"]:
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
if x_axis in ["assignees__display_name"]:
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(item)]
if len(assignee):
generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows.append(tuple(generated_row))
# If segment is ["assignees__email"] then replace segment_zero rows with first and last names
if segmented in ["assignees__email"]:
# If segment is ["assignees__display_name"] then replace segment_zero rows with first and last names
if segmented in ["assignees__display_name"]:
for index, segm in enumerate(row_zero[2:]):
# find the name of the user
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(segm)]
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(segm)]
if len(assignee):
row_zero[index] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
@@ -141,8 +141,8 @@ def analytic_export_task(email, data, slug):
else distribution.get(item)[0].get("estimate "),
]
# x-axis replacement to names
if x_axis in ["assignees__email"]:
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
if x_axis in ["assignees__display_name"]:
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(item)]
if len(assignee):
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
+42 -42
View File
@@ -48,7 +48,7 @@ def track_name(
field="name",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the name to {requested_data.get('name')}",
comment=f"updated the name to {requested_data.get('name')}",
)
)
@@ -75,7 +75,7 @@ def track_parent(
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to None",
comment=f"updated the parent issue to None",
old_identifier=old_parent.id,
new_identifier=None,
)
@@ -95,7 +95,7 @@ def track_parent(
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to {new_parent.name}",
comment=f"updated the parent issue to {new_parent.name}",
old_identifier=old_parent.id if old_parent is not None else None,
new_identifier=new_parent.id,
)
@@ -123,7 +123,7 @@ def track_priority(
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to None",
comment=f"updated the priority to None",
)
)
else:
@@ -137,7 +137,7 @@ def track_priority(
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
comment=f"updated the priority to {requested_data.get('priority')}",
)
)
@@ -165,7 +165,7 @@ def track_state(
field="state",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the state to {new_state.name}",
comment=f"updated the state to {new_state.name}",
old_identifier=old_state.id,
new_identifier=new_state.id,
)
@@ -194,7 +194,7 @@ def track_description(
field="description",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the description to {requested_data.get('description_html')}",
comment=f"updated the description to {requested_data.get('description_html')}",
)
)
@@ -220,7 +220,7 @@ def track_target_date(
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to None",
comment=f"updated the target date to None",
)
)
else:
@@ -234,7 +234,7 @@ def track_target_date(
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to {requested_data.get('target_date')}",
comment=f"updated the target date to {requested_data.get('target_date')}",
)
)
@@ -260,7 +260,7 @@ def track_start_date(
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to None",
comment=f"updated the start date to None",
)
)
else:
@@ -274,7 +274,7 @@ def track_start_date(
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to {requested_data.get('start_date')}",
comment=f"updated the start date to {requested_data.get('start_date')}",
)
)
@@ -303,7 +303,7 @@ def track_labels(
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added label {label.name}",
comment=f"added label {label.name}",
new_identifier=label.id,
old_identifier=None,
)
@@ -324,7 +324,7 @@ def track_labels(
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed label {label.name}",
comment=f"removed label {label.name}",
old_identifier=label.id,
new_identifier=None,
)
@@ -353,12 +353,12 @@ def track_assignees(
actor=actor,
verb="updated",
old_value="",
new_value=assignee.email,
new_value=assignee.display_name,
field="assignees",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added assignee {assignee.email}",
new_identifier=actor.id,
comment=f"added assignee {assignee.display_name}",
new_identifier=assignee.id,
)
)
@@ -374,13 +374,13 @@ def track_assignees(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=assignee.email,
old_value=assignee.display_name,
new_value="",
field="assignees",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed assignee {assignee.email}",
old_identifier=actor.id,
comment=f"removed assignee {assignee.display_name}",
old_identifier=assignee.id,
)
)
@@ -419,7 +419,7 @@ def track_blocks(
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocking issue {issue.project.identifier}-{issue.sequence_id}",
comment=f"added blocking issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
@@ -441,7 +441,7 @@ def track_blocks(
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocking issue {issue.project.identifier}-{issue.sequence_id}",
comment=f"removed blocking issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
@@ -481,7 +481,7 @@ def track_blockings(
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocked by issue {issue.project.identifier}-{issue.sequence_id}",
comment=f"added blocked by issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
@@ -503,7 +503,7 @@ def track_blockings(
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocked by issue {issue.project.identifier}-{issue.sequence_id}",
comment=f"removed blocked by issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
@@ -517,7 +517,7 @@ def create_issue_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created the issue",
comment=f"created the issue",
verb="created",
actor=actor,
)
@@ -539,7 +539,7 @@ def track_estimate_points(
field="estimate_point",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the estimate point to None",
comment=f"updated the estimate point to None",
)
)
else:
@@ -553,7 +553,7 @@ def track_estimate_points(
field="estimate_point",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the estimate point to {requested_data.get('estimate_point')}",
comment=f"updated the estimate point to {requested_data.get('estimate_point')}",
)
)
@@ -567,7 +567,7 @@ def track_archive_at(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} has restored the issue",
comment=f"has restored the issue",
verb="updated",
actor=actor,
field="archived_at",
@@ -661,7 +661,7 @@ def delete_issue_activity(
IssueActivity(
project=project,
workspace=project.workspace,
comment=f"{actor.email} deleted the issue",
comment=f"deleted the issue",
verb="deleted",
actor=actor,
field="issue",
@@ -682,7 +682,7 @@ def create_comment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created a comment",
comment=f"created a comment",
verb="created",
actor=actor,
field="comment",
@@ -707,7 +707,7 @@ def update_comment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated a comment",
comment=f"updated a comment",
verb="updated",
actor=actor,
field="comment",
@@ -728,7 +728,7 @@ def delete_comment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} deleted the comment",
comment=f"deleted the comment",
verb="deleted",
actor=actor,
field="comment",
@@ -766,7 +766,7 @@ def create_cycle_issue_activity(
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated cycle from {old_cycle.name} to {new_cycle.name}",
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id,
new_identifier=new_cycle.id,
)
@@ -787,7 +787,7 @@ def create_cycle_issue_activity(
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added cycle {cycle.name}",
comment=f"added cycle {cycle.name}",
new_identifier=cycle.id,
)
)
@@ -816,7 +816,7 @@ def delete_cycle_issue_activity(
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed this issue from {cycle.name if cycle is not None else None}",
comment=f"removed this issue from {cycle.name if cycle is not None else None}",
old_identifier=cycle.id if cycle is not None else None,
)
)
@@ -852,7 +852,7 @@ def create_module_issue_activity(
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated module from {old_module.name} to {new_module.name}",
comment=f"updated module from {old_module.name} to {new_module.name}",
old_identifier=old_module.id,
new_identifier=new_module.id,
)
@@ -872,7 +872,7 @@ def create_module_issue_activity(
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added module {module.name}",
comment=f"added module {module.name}",
new_identifier=module.id,
)
)
@@ -901,7 +901,7 @@ def delete_module_issue_activity(
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed this issue from {module.name if module is not None else None}",
comment=f"removed this issue from {module.name if module is not None else None}",
old_identifier=module.id if module is not None else None,
)
)
@@ -920,7 +920,7 @@ def create_link_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created a link",
comment=f"created a link",
verb="created",
actor=actor,
field="link",
@@ -944,7 +944,7 @@ def update_link_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated a link",
comment=f"updated a link",
verb="updated",
actor=actor,
field="link",
@@ -969,7 +969,7 @@ def delete_link_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} deleted the link",
comment=f"deleted the link",
verb="deleted",
actor=actor,
field="link",
@@ -992,7 +992,7 @@ def create_attachment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} created an attachment",
comment=f"created an attachment",
verb="created",
actor=actor,
field="attachment",
@@ -1010,7 +1010,7 @@ def delete_attachment_activity(
issue_id=issue_id,
project=project,
workspace=project.workspace,
comment=f"{actor.email} deleted the attachment",
comment=f"deleted the attachment",
verb="deleted",
actor=actor,
field="attachment",
@@ -0,0 +1,191 @@
# Python imports
import csv
import io
# Django imports
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
from django.utils import timezone
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Issue
@shared_task
def issue_export_task(email, data, slug, exporter_name):
try:
project_ids = data.get("project_id", [])
issues_filter = {"workspace__slug": slug}
if project_ids:
issues_filter["project_id__in"] = project_ids
issues = (
Issue.objects.filter(**issues_filter)
.select_related("project", "workspace", "state", "parent", "created_by")
.prefetch_related(
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
)
.values_list(
"project__identifier",
"sequence_id",
"name",
"description_stripped",
"priority",
"start_date",
"target_date",
"state__name",
"project__name",
"created_at",
"updated_at",
"completed_at",
"archived_at",
"issue_cycle__cycle__name",
"issue_cycle__cycle__start_date",
"issue_cycle__cycle__end_date",
"issue_module__module__name",
"issue_module__module__start_date",
"issue_module__module__target_date",
"created_by__first_name",
"created_by__last_name",
"assignees__first_name",
"assignees__last_name",
"labels__name",
)
)
# CSV header
header = [
"Issue ID",
"Project",
"Name",
"Description",
"State",
"Priority",
"Created By",
"Assignee",
"Labels",
"Cycle Name",
"Cycle Start Date",
"Cycle End Date",
"Module Name",
"Module Start Date",
"Module Target Date",
"Created At"
"Updated At"
"Completed At"
"Archived At"
]
# Prepare the CSV data
rows = [header]
# Write data for each issue
for issue in issues:
(
project_identifier,
sequence_id,
name,
description,
priority,
start_date,
target_date,
state_name,
project_name,
created_at,
updated_at,
completed_at,
archived_at,
cycle_name,
cycle_start_date,
cycle_end_date,
module_name,
module_start_date,
module_target_date,
created_by_first_name,
created_by_last_name,
assignees_first_names,
assignees_last_names,
labels_names,
) = issue
created_by_fullname = (
f"{created_by_first_name} {created_by_last_name}"
if created_by_first_name and created_by_last_name
else ""
)
assignees_names = ""
if assignees_first_names and assignees_last_names:
assignees_names = ", ".join(
[
f"{assignees_first_name} {assignees_last_name}"
for assignees_first_name, assignees_last_name in zip(
assignees_first_names, assignees_last_names
)
]
)
labels_names = ", ".join(labels_names) if labels_names else ""
row = [
f"{project_identifier}-{sequence_id}",
project_name,
name,
description,
state_name,
priority,
created_by_fullname,
assignees_names,
labels_names,
cycle_name,
cycle_start_date,
cycle_end_date,
module_name,
module_start_date,
module_target_date,
start_date,
target_date,
created_at,
updated_at,
completed_at,
archived_at,
]
rows.append(row)
# Create CSV file in-memory
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
# Write CSV data to the buffer
for row in rows:
writer.writerow(row)
subject = "Your Issue Export is ready"
context = {
"username": exporter_name,
}
html_content = render_to_string("emails/exports/issues.html", context)
text_content = strip_tags(html_content)
csv_buffer.seek(0)
msg = EmailMultiAlternatives(
subject, text_content, settings.EMAIL_FROM, [email]
)
msg.attach(f"{slug}-issues-{timezone.now().date()}.csv", csv_buffer.read(), "text/csv")
msg.send(fail_silently=False)
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return
@@ -89,8 +89,8 @@ class Migration(migrations.Migration):
field=models.JSONField(default=plane.db.models.workspace.get_default_props),
),
migrations.AddField(
model_name="projectmember",
name="sort_order",
model_name='projectmember',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.RunPython(update_project_member_sort_order),
@@ -0,0 +1,81 @@
# Generated by Django 4.2.3 on 2023-08-01 06:02
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.project
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0039_auto_20230723_2203'),
]
operations = [
migrations.AddField(
model_name='projectmember',
name='preferences',
field=models.JSONField(default=plane.db.models.project.get_default_preferences),
),
migrations.AddField(
model_name='user',
name='cover_image',
field=models.URLField(blank=True, max_length=800, null=True),
),
migrations.CreateModel(
name='IssueReaction',
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)),
('reaction', models.CharField(max_length=20)),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_reactions', to=settings.AUTH_USER_MODEL)),
('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')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_reactions', to='db.issue')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('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')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Issue Reaction',
'verbose_name_plural': 'Issue Reactions',
'db_table': 'issue_reactions',
'ordering': ('-created_at',),
'unique_together': {('issue', 'actor', 'reaction')},
},
),
migrations.CreateModel(
name='CommentReaction',
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)),
('reaction', models.CharField(max_length=20)),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_reactions', to=settings.AUTH_USER_MODEL)),
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_reactions', to='db.issuecomment')),
('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')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('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')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Comment Reaction',
'verbose_name_plural': 'Comment Reactions',
'db_table': 'comment_reactions',
'ordering': ('-created_at',),
'unique_together': {('comment', 'actor', 'reaction')},
},
),
migrations.AlterField(
model_name='project',
name='identifier',
field=models.CharField(max_length=12, verbose_name='Project Identifier'),
),
migrations.AlterField(
model_name='projectidentifier',
name='name',
field=models.CharField(max_length=12),
),
]
@@ -0,0 +1,101 @@
# Generated by Django 4.2.3 on 2023-08-04 09:12
import string
import random
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def generate_display_name(apps, schema_editor):
UserModel = apps.get_model("db", "User")
updated_users = []
for obj in UserModel.objects.all():
obj.display_name = (
obj.email.split("@")[0]
if len(obj.email.split("@"))
else "".join(random.choice(string.ascii_letters) for _ in range(6))
)
updated_users.append(obj)
UserModel.objects.bulk_update(updated_users, ["display_name"], batch_size=100)
def rectify_field_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
for obj in Model.objects.filter(field="assignee"):
obj.field = "assignees"
updated_activity.append(obj)
Model.objects.bulk_update(updated_activity, ["field"], batch_size=100)
def update_assignee_issue_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
updated_activity = []
# Get all the users
User = apps.get_model("db", "User")
users = User.objects.values("id", "email", "display_name")
for obj in Model.objects.filter(field="assignees"):
if bool(obj.new_value) and not bool(obj.old_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.new_value
]
if assigned_user:
obj.new_value = assigned_user[0].get("display_name")
obj.new_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
if bool(obj.old_value) and not bool(obj.new_value):
# Get user from list
assigned_user = [
user for user in users if user.get("email") == obj.old_value
]
if assigned_user:
obj.old_value = assigned_user[0].get("display_name")
obj.old_identifier = assigned_user[0].get("id")
# Update the comment
words = obj.comment.split()
words[-1] = assigned_user[0].get("display_name")
obj.comment = " ".join(words)
updated_activity.append(obj)
Model.objects.bulk_update(
updated_activity,
["old_value", "new_value", "old_identifier", "new_identifier", "comment"],
batch_size=200,
)
def update_name_activity(apps, schema_editor):
Model = apps.get_model("db", "IssueActivity")
update_activity = []
for obj in Model.objects.filter(field="name"):
obj.comment = obj.comment.replace("start date", "name")
update_activity.append(obj)
Model.objects.bulk_update(update_activity, ["comment"], batch_size=1000)
class Migration(migrations.Migration):
dependencies = [
("db", "0040_projectmember_preferences_user_cover_image_and_more"),
]
operations = [
migrations.AddField(
model_name="user",
name="display_name",
field=models.CharField(default="", max_length=255),
),
migrations.RunPython(generate_display_name),
migrations.RunPython(rectify_field_issue_activity),
migrations.RunPython(update_assignee_issue_activity),
migrations.RunPython(update_name_activity),
]
+1 -1
View File
@@ -161,7 +161,7 @@ class ProjectMember(ProjectBaseModel):
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = ProjectMember.objects.filter(
workspace_id=self.project.workspace_id, member_id=self.member_id
workspace_id=self.project.workspace_id, member=self.member
).aggregate(smallest=models.Min("sort_order"))["smallest"]
# Project ordering
+12 -1
View File
@@ -1,6 +1,7 @@
# Python imports
from enum import unique
import uuid
import string
import random
# Django imports
from django.db import models
@@ -18,6 +19,7 @@ from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
def get_default_onboarding():
return {
"profile_complete": False,
@@ -26,6 +28,7 @@ def get_default_onboarding():
"workspace_join": False,
}
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
@@ -81,6 +84,7 @@ class User(AbstractBaseUser, PermissionsMixin):
role = models.CharField(max_length=300, null=True, blank=True)
is_bot = models.BooleanField(default=False)
theme = models.JSONField(default=dict)
display_name = models.CharField(max_length=255, default="")
is_tour_completed = models.BooleanField(default=False)
onboarding_step = models.JSONField(default=get_default_onboarding)
@@ -107,6 +111,13 @@ class User(AbstractBaseUser, PermissionsMixin):
self.token = uuid.uuid4().hex + uuid.uuid4().hex
self.token_updated_at = timezone.now()
if not self.display_name:
self.display_name = (
self.email.split("@")[0]
if len(self.email.split("@"))
else "".join(random.choice(string.ascii_letters) for _ in range(6))
)
if self.is_superuser:
self.is_staff = True
@@ -0,0 +1,9 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
Dear {{username}},<br/>
Your requested Issue's data has been successfully exported from Plane. The export includes all relevant information about issues you requested from your selected projects.</br>
Please find the attachment and download the CSV file. If you have any questions or need further assistance, please don't hesitate to contact our support team at <a href = "mailto: engineering@plane.com">engineering@plane.so</a>. We're here to help!</br>
Thank you for using Plane. We hope this export will aid you in effectively managing your projects.</br>
Regards,
Team Plane
</html>
@@ -14,17 +14,17 @@ type Props = {
export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) => {
let tooltipValue: string | number = "";
const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
if (!assignee) return "No assignee";
return assignee.assignees__display_name || "No assignee";
};
if (params.segment) {
if (DATE_KEYS.includes(params.segment)) tooltipValue = renderMonthAndYear(datum.id);
else if (params.segment === "assignees__email") {
const assignee = analytics.extras.assignee_details.find(
(a) => a.assignees__email === datum.id
);
if (assignee)
tooltipValue = assignee.assignees__first_name + " " + assignee.assignees__last_name;
else tooltipValue = "No assignees";
} else tooltipValue = datum.id;
else tooltipValue = datum.id;
} else {
if (DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue;
else tooltipValue = datum.id === "count" ? "Issue count" : "Estimate";
@@ -49,7 +49,10 @@ export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) =>
: ""
}`}
>
{tooltipValue}:
{params.segment === "assignees__id"
? renderAssigneeName(tooltipValue.toString())
: tooltipValue}
:
</span>
<span>{datum.value}</span>
</div>
@@ -29,6 +29,14 @@ export const AnalyticsGraph: React.FC<Props> = ({
yAxisKey,
fullScreen,
}) => {
const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
if (!assignee) return "?";
return assignee.assignees__display_name || "?";
};
const generateYAxisTickValues = () => {
if (!analytics) return [];
@@ -70,17 +78,17 @@ export const AnalyticsGraph: React.FC<Props> = ({
height={fullScreen ? "400px" : "300px"}
margin={{
right: 20,
bottom: params.x_axis === "assignees__email" ? 50 : longestXAxisLabel.length * 5 + 20,
bottom: params.x_axis === "assignees__id" ? 50 : longestXAxisLabel.length * 5 + 20,
}}
axisBottom={{
tickSize: 0,
tickPadding: 10,
tickRotation: barGraphData.data.length > 7 ? -45 : 0,
renderTick:
params.x_axis === "assignees__email"
params.x_axis === "assignees__id"
? (datum) => {
const avatar = analytics.extras.assignee_details?.find(
(a) => a?.assignees__email === datum?.value
(a) => a?.assignees__display_name === datum?.value
)?.assignees__avatar;
if (avatar && avatar !== "")
@@ -101,7 +109,11 @@ export const AnalyticsGraph: React.FC<Props> = ({
<g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
{datum.value && datum.value !== "None"
{params.x_axis === "assignees__id"
? datum.value && datum.value !== "None"
? renderAssigneeName(datum.value)[0].toUpperCase()
: "?"
: datum.value && datum.value !== "None"
? `${datum.value}`.toUpperCase()[0]
: "?"}
</text>
@@ -277,9 +277,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>
{cycleDetails.owned_by?.first_name} {cycleDetails.owned_by?.last_name}
</span>
<span>{cycleDetails.owned_by?.display_name}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6>
@@ -305,10 +303,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>
{moduleDetails.lead_detail?.first_name}{" "}
{moduleDetails.lead_detail?.last_name}
</span>
<span>{moduleDetails.lead_detail?.display_name}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6>
@@ -22,15 +22,12 @@ type Props = {
};
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => {
const renderAssigneeName = (email: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__email === email);
const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
if (!assignee) return "No assignee";
if (assignee.assignees__first_name !== "")
return assignee.assignees__first_name + " " + assignee.assignees__last_name;
return email;
return assignee.assignees__display_name || "No assignee";
};
return (
@@ -65,10 +62,10 @@ export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, param
}}
/>
)}
{DATE_KEYS.includes(params.segment ?? "")
? renderMonthAndYear(key)
: params.segment === "assignees__email"
{params.segment === "assignees__id"
? renderAssigneeName(key)
: DATE_KEYS.includes(params.segment ?? "")
? renderMonthAndYear(key)
: key}
</div>
</th>
@@ -108,7 +105,7 @@ export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, param
}}
/>
)}
{params.x_axis === "assignees__email"
{params.x_axis === "assignees__id"
? renderAssigneeName(`${item.name}`)
: addSpaceIfCamelCase(`${item.name}`)}
</td>
@@ -1,7 +1,7 @@
type Props = {
users: {
avatar: string | null;
email: string | null;
display_name: string | null;
firstName: string;
lastName: string;
count: number;
@@ -16,7 +16,7 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
<div className="mt-3 space-y-3">
{users.map((user) => (
<div
key={user.email ?? "None"}
key={user.display_name ?? "None"}
className="flex items-start justify-between gap-4 text-xs"
>
<div className="flex items-center gap-2">
@@ -25,16 +25,16 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
<img
src={user.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded-full"
alt={user.email ?? "None"}
alt={user.display_name ?? "None"}
/>
</div>
) : (
<div className="grid place-items-center flex-shrink-0 rounded-full bg-gray-700 text-[11px] capitalize text-white h-4 w-4">
{user.firstName !== "" ? user.firstName[0] : "?"}
{user.display_name !== "" ? user?.display_name?.[0] : "?"}
</div>
)}
<span className="break-words text-custom-text-200">
{user.firstName !== "" ? `${user.firstName} ${user.lastName}` : "No assignee"}
{user.display_name !== "" ? `${user.display_name}` : "No assignee"}
</span>
</div>
<span className="flex-shrink-0">{user.count}</span>
@@ -56,9 +56,9 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
<AnalyticsLeaderboard
users={defaultAnalytics.most_issue_created_user?.map((user) => ({
avatar: user?.created_by__avatar,
email: user?.created_by__email,
firstName: user?.created_by__first_name,
lastName: user?.created_by__last_name,
display_name: user?.created_by__display_name,
count: user?.count,
}))}
title="Most issues created"
@@ -66,9 +66,9 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
<AnalyticsLeaderboard
users={defaultAnalytics.most_issue_closed_user?.map((user) => ({
avatar: user?.assignees__avatar,
email: user?.assignees__email,
firstName: user?.assignees__first_name,
lastName: user?.assignees__last_name,
display_name: user?.assignees__display_name,
count: user?.count,
}))}
title="Most issues closed"
@@ -16,23 +16,20 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
{defaultAnalytics.pending_issue_user.length > 0 ? (
<BarGraph
data={defaultAnalytics.pending_issue_user}
indexBy="assignees__email"
indexBy="assignees__display_name"
keys={["count"]}
height="250px"
colors={() => `#f97316`}
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)}
tooltip={(datum) => {
const assignee = defaultAnalytics.pending_issue_user.find(
(a) => a.assignees__email === `${datum.indexValue}`
(a) => a.assignees__display_name === `${datum.indexValue}`
);
return (
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
<span className="font-medium text-custom-text-200">
{assignee
? assignee.assignees__first_name + " " + assignee.assignees__last_name
: "No assignee"}
:{" "}
{assignee ? assignee.assignees__display_name : "No assignee"}:{" "}
</span>
{datum.value}
</div>
@@ -5,6 +5,8 @@ import { Command } from "cmdk";
import { THEMES_OBJ } from "constants/themes";
import { useTheme } from "next-themes";
import { SettingIcon } from "components/icons";
import userService from "services/user.service";
import useUser from "hooks/use-user";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
@@ -12,24 +14,50 @@ type Props = {
export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
const [mounted, setMounted] = useState(false);
const { setTheme } = useTheme();
const { user, mutateUser } = useUser();
const updateUserTheme = (newTheme: string) => {
if (!user) return;
setTheme(newTheme);
mutateUser((prevData) => {
if (!prevData) return prevData;
return {
...prevData,
theme: {
...prevData.theme,
theme: newTheme,
},
};
}, false);
userService.updateUser({
theme: {
...user.theme,
theme: newTheme,
},
});
};
// useEffect only runs on the client, so now we can safely show the UI
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
if (!mounted) return null;
return (
<>
{THEMES_OBJ.map((theme) => (
{THEMES_OBJ.filter((t) => t.value !== "custom").map((theme) => (
<Command.Item
key={theme.value}
onSelect={() => {
setTheme(theme.value);
updateUserTheme(theme.value);
setIsPaletteOpen(false);
}}
className="focus:outline-none"
@@ -24,8 +24,12 @@ import issuesService from "services/issues.service";
import inboxService from "services/inbox.service";
// fetch keys
import { INBOX_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
export const CommandPalette: React.FC = () => {
const store: any = useMobxStore();
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
@@ -74,37 +78,36 @@ export const CommandPalette: React.FC = () => {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const singleShortcutKeys = ["p", "v", "d", "h", "q", "m"];
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
if (!key) return;
const keyPressed = key.toLowerCase();
// if on input, textarea or editor, don't do anything
if (
!(e.target instanceof HTMLTextAreaElement) &&
!(e.target instanceof HTMLInputElement) &&
!(e.target as Element).classList?.contains("remirror-editor")
) {
if ((ctrlKey || metaKey) && keyPressed === "k") {
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement ||
(e.target as Element).classList?.contains("remirror-editor")
)
return;
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
if (!key) return;
const keyPressed = key.toLowerCase();
const cmdClicked = ctrlKey || metaKey;
if (cmdClicked) {
if (keyPressed === "k") {
e.preventDefault();
setIsPaletteOpen(true);
} else if ((ctrlKey || metaKey) && keyPressed === "c") {
if (altKey) {
e.preventDefault();
copyIssueUrlToClipboard();
}
} else if (keyPressed === "c") {
} else if (keyPressed === "c" && altKey) {
e.preventDefault();
setIsIssueModalOpen(true);
} else if ((ctrlKey || metaKey) && keyPressed === "b") {
copyIssueUrlToClipboard();
} else if (keyPressed === "b") {
e.preventDefault();
toggleCollapsed();
} else if (key === "Delete") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
} else if (
singleShortcutKeys.includes(keyPressed) &&
(ctrlKey || metaKey || altKey || shiftKey)
) {
e.preventDefault();
}
} else {
if (keyPressed === "c") {
setIsIssueModalOpen(true);
} else if (keyPressed === "p") {
setIsProjectModalOpen(true);
} else if (keyPressed === "v") {
@@ -117,10 +120,13 @@ export const CommandPalette: React.FC = () => {
setIsCreateCycleModalOpen(true);
} else if (keyPressed === "m") {
setIsCreateModuleModalOpen(true);
} else if (keyPressed === "backspace" || keyPressed === "delete") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
}
}
},
[toggleCollapsed, copyIssueUrlToClipboard]
[copyIssueUrlToClipboard, toggleCollapsed]
);
useEffect(() => {
@@ -34,15 +34,12 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue,
const options =
members?.map(({ member }) => ({
value: member.id,
query:
(member.first_name && member.first_name !== "" ? member.first_name : member.email) +
" " +
member.last_name ?? "",
query: member.display_name,
content: (
<>
<div className="flex items-center gap-2">
<Avatar user={member} />
{member.first_name && member.first_name !== "" ? member.first_name : member.email}
{member.display_name}
</div>
{issue.assignees.includes(member.id) && (
<div>
@@ -1,5 +1,7 @@
import { useRouter } from "next/router";
// icons
import { Icon } from "components/ui";
import { Icon, Tooltip } from "components/ui";
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon } from "components/icons";
// helpers
@@ -8,26 +10,65 @@ import { capitalizeFirstLetter } from "helpers/string.helper";
// types
import { IIssueActivity } from "types";
export const activityDetails: {
const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Tooltip
tooltipContent={
activity.issue_detail ? activity.issue_detail.name : "This issue has been deleted"
}
>
<a
href={`/${workspaceSlug}/projects/${activity.project}/issues/${activity.issue}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
{activity.issue_detail
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
: "Issue"}
<Icon iconName="launch" className="!text-xs" />
</a>
</Tooltip>
);
};
const activityDetails: {
[key: string]: {
message: (activity: IIssueActivity) => React.ReactNode;
message: (activity: IIssueActivity, showIssue: boolean) => React.ReactNode;
icon: React.ReactNode;
};
} = {
assignees: {
message: (activity) => {
message: (activity, showIssue) => {
if (activity.old_value === "")
return (
<>
added a new assignee{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue && (
<>
{" "}
to <IssueLink activity={activity} />
</>
)}
.
</>
);
else
return (
<>
removed the assignee{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
.
</>
);
},
@@ -41,7 +82,7 @@ export const activityDetails: {
icon: <Icon iconName="archive" className="!text-sm" aria-hidden="true" />,
},
attachment: {
message: (activity) => {
message: (activity, showIssue) => {
if (activity.verb === "created")
return (
<>
@@ -55,9 +96,27 @@ export const activityDetails: {
attachment
<Icon iconName="launch" className="!text-xs" />
</a>
{showIssue && (
<>
{" "}
to <IssueLink activity={activity} />
</>
)}
</>
);
else
return (
<>
removed an attachment
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
.
</>
);
else return "removed an attachment.";
},
icon: <Icon iconName="attach_file" className="!text-sm" aria-hidden="true" />,
},
@@ -126,17 +185,47 @@ export const activityDetails: {
icon: <Icon iconName="contrast" className="!text-sm" aria-hidden="true" />,
},
description: {
message: (activity) => "updated the description.",
message: (activity, showIssue) => (
<>
updated the description
{showIssue && (
<>
{" "}
of <IssueLink activity={activity} />
</>
)}
.
</>
),
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
},
estimate_point: {
message: (activity) => {
if (!activity.new_value) return "removed the estimate point.";
message: (activity, showIssue) => {
if (!activity.new_value)
return (
<>
removed the estimate point
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
.
</>
);
else
return (
<>
set the estimate point to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue && (
<>
{" "}
for <IssueLink activity={activity} />
</>
)}
.
</>
);
},
@@ -150,7 +239,7 @@ export const activityDetails: {
icon: <Icon iconName="stack" className="!text-sm" aria-hidden="true" />,
},
labels: {
message: (activity) => {
message: (activity, showIssue) => {
if (activity.old_value === "")
return (
<>
@@ -165,6 +254,12 @@ export const activityDetails: {
/>
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
</span>
{showIssue && (
<>
{" "}
to <IssueLink activity={activity} />
</>
)}
</>
);
else
@@ -181,13 +276,19 @@ export const activityDetails: {
/>
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
</span>
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
</>
);
},
icon: <Icon iconName="sell" className="!text-sm" aria-hidden="true" />,
},
link: {
message: (activity) => {
message: (activity, showIssue) => {
if (activity.verb === "created")
return (
<>
@@ -200,8 +301,14 @@ export const activityDetails: {
>
link
<Icon iconName="launch" className="!text-xs" />
</a>{" "}
to the issue.
</a>
{showIssue && (
<>
{" "}
to <IssueLink activity={activity} />
</>
)}
.
</>
);
else
@@ -216,8 +323,14 @@ export const activityDetails: {
>
link
<Icon iconName="launch" className="!text-xs" />
</a>{" "}
from the issue.
</a>
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
.
</>
);
},
@@ -250,52 +363,136 @@ export const activityDetails: {
icon: <Icon iconName="dataset" className="!text-sm" aria-hidden="true" />,
},
name: {
message: (activity) => `set the name to ${activity.new_value}.`,
message: (activity, showIssue) => (
<>
set the name to {activity.new_value}
{showIssue && (
<>
{" "}
of <IssueLink activity={activity} />
</>
)}
.
</>
),
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
},
parent: {
message: (activity) => {
message: (activity, showIssue) => {
if (!activity.new_value)
return (
<>
removed the parent{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
.
</>
);
else
return (
<>
set the parent to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue && (
<>
{" "}
for <IssueLink activity={activity} />
</>
)}
.
</>
);
},
icon: <Icon iconName="supervised_user_circle" className="!text-sm" aria-hidden="true" />,
},
priority: {
message: (activity) => (
message: (activity, showIssue) => (
<>
set the priority to{" "}
<span className="font-medium text-custom-text-100">
{activity.new_value ? capitalizeFirstLetter(activity.new_value) : "None"}
</span>
{showIssue && (
<>
{" "}
for <IssueLink activity={activity} />
</>
)}
.
</>
),
icon: <Icon iconName="signal_cellular_alt" className="!text-sm" aria-hidden="true" />,
},
start_date: {
message: (activity, showIssue) => {
if (!activity.new_value)
return (
<>
removed the start date
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
.
</>
);
else
return (
<>
set the start date to{" "}
<span className="font-medium text-custom-text-100">
{renderShortDateWithYearFormat(activity.new_value)}
</span>
{showIssue && (
<>
{" "}
for <IssueLink activity={activity} />
</>
)}
.
</>
);
},
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />,
},
state: {
message: (activity) => (
message: (activity, showIssue) => (
<>
set the state to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue && (
<>
{" "}
for <IssueLink activity={activity} />
</>
)}
.
</>
),
icon: <Squares2X2Icon className="h-3 w-3" aria-hidden="true" />,
},
target_date: {
message: (activity) => {
if (!activity.new_value) return "removed the due date.";
message: (activity, showIssue) => {
if (!activity.new_value)
return (
<>
removed the due date
{showIssue && (
<>
{" "}
from <IssueLink activity={activity} />
</>
)}
.
</>
);
else
return (
<>
@@ -303,6 +500,12 @@ export const activityDetails: {
<span className="font-medium text-custom-text-100">
{renderShortDateWithYearFormat(activity.new_value)}
</span>
{showIssue && (
<>
{" "}
for <IssueLink activity={activity} />
</>
)}
.
</>
);
@@ -310,3 +513,19 @@ export const activityDetails: {
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />,
},
};
export const ActivityIcon = ({ activity }: { activity: IIssueActivity }) => (
<>{activityDetails[activity.field as keyof typeof activityDetails]?.icon}</>
);
export const ActivityMessage = ({
activity,
showIssue = false,
}: {
activity: IIssueActivity;
showIssue?: boolean;
}) => (
<>
{activityDetails[activity.field as keyof typeof activityDetails]?.message(activity, showIssue)}
</>
);
-331
View File
@@ -1,331 +0,0 @@
import React from "react";
import Link from "next/link";
// icons
import {
ArrowTopRightOnSquareIcon,
ChatBubbleLeftEllipsisIcon,
Squares2X2Icon,
} from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon } from "components/icons";
import { Icon } from "components/ui";
// helpers
import { renderShortDateWithYearFormat, timeAgo } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import RemirrorRichTextEditor from "components/rich-text-editor";
const activityDetails: {
[key: string]: {
message?: string;
icon: JSX.Element;
};
} = {
assignee: {
message: "removed the assignee",
icon: <Icon iconName="group" className="!text-sm" aria-hidden="true" />,
},
assignees: {
message: "added a new assignee",
icon: <Icon iconName="group" className="!text-sm" aria-hidden="true" />,
},
blocks: {
message: "marked this issue being blocked by",
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
},
blocking: {
message: "marked this issue is blocking",
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
},
cycles: {
message: "set the cycle to",
icon: <Icon iconName="contrast" className="!text-sm" aria-hidden="true" />,
},
labels: {
icon: <Icon iconName="sell" className="!text-sm" aria-hidden="true" />,
},
modules: {
message: "set the module to",
icon: <Icon iconName="dataset" className="!text-sm" aria-hidden="true" />,
},
state: {
message: "set the state to",
icon: <Squares2X2Icon className="h-3 w-3 text-custom-text-200" aria-hidden="true" />,
},
priority: {
message: "set the priority to",
icon: <Icon iconName="signal_cellular_alt" className="!text-sm" aria-hidden="true" />,
},
name: {
message: "set the name to",
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
},
description: {
message: "updated the description.",
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
},
estimate_point: {
message: "set the estimate point to",
icon: <Icon iconName="change_history" className="!text-sm" aria-hidden="true" />,
},
target_date: {
message: "set the due date to",
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />,
},
parent: {
message: "set the parent to",
icon: <Icon iconName="supervised_user_circle" className="!text-sm" aria-hidden="true" />,
},
issue: {
message: "deleted the issue.",
icon: <Icon iconName="delete" className="!text-sm" aria-hidden="true" />,
},
estimate: {
message: "updated the estimate",
icon: <Icon iconName="change_history" className="!text-sm" aria-hidden="true" />,
},
link: {
message: "updated the link",
icon: <Icon iconName="link" className="!text-sm" aria-hidden="true" />,
},
attachment: {
message: "updated the attachment",
icon: <Icon iconName="attach_file" className="!text-sm" aria-hidden="true" />,
},
archived_at: {
message: "archived",
icon: <Icon iconName="archive" className="!text-sm text-custom-text-200" aria-hidden="true" />,
},
};
export const Feeds: React.FC<any> = ({ activities }) => (
<div>
<ul role="list" className="-mb-4">
{activities.map((activity: any, activityIdx: number) => {
// determines what type of action is performed
let action = activityDetails[activity.field as keyof typeof activityDetails]?.message;
if (activity.field === "labels") {
action = activity.new_value !== "" ? "added a new label" : "removed the label";
} else if (activity.field === "blocking") {
action =
activity.new_value !== ""
? "marked this issue is blocking"
: "removed the issue from blocking";
} else if (activity.field === "blocks") {
action =
activity.new_value !== "" ? "marked this issue being blocked by" : "removed blocker";
} else if (activity.field === "target_date") {
action =
activity.new_value && activity.new_value !== ""
? "set the due date to"
: "removed the due date";
} else if (activity.field === "parent") {
action =
activity.new_value && activity.new_value !== ""
? "set the parent to"
: "removed the parent";
} else if (activity.field === "priority") {
action =
activity.new_value && activity.new_value !== ""
? "set the priority to"
: "removed the priority";
} else if (activity.field === "description") {
action = "updated the";
} else if (activity.field === "attachment") {
action = `${activity.verb} the`;
} else if (activity.field === "link") {
action = `${activity.verb} the`;
} else if (activity.field === "archived_at") {
action =
activity.new_value && activity.new_value === "restore"
? "restored the issue"
: "archived the issue";
}
// for values that are after the action clause
let value: any = activity.new_value ? activity.new_value : activity.old_value;
if (
activity.verb === "created" &&
activity.field !== "cycles" &&
activity.field !== "modules" &&
activity.field !== "attachment" &&
activity.field !== "link" &&
activity.field !== "estimate"
) {
const { workspace_detail, project, issue } = activity;
value = (
<span className="text-custom-text-200">
created{" "}
<Link href={`/${workspace_detail.slug}/projects/${project}/issues/${issue}`}>
<a className="inline-flex items-center hover:underline">
this issue. <ArrowTopRightOnSquareIcon className="ml-1 h-3.5 w-3.5" />
</a>
</Link>
</span>
);
} else if (activity.field === "state") {
value = activity.new_value ? addSpaceIfCamelCase(activity.new_value) : "None";
} else if (activity.field === "labels") {
let name;
let id = "#000000";
if (activity.new_value !== "") {
name = activity.new_value;
id = activity.new_identifier ? activity.new_identifier : id;
} else {
name = activity.old_value;
id = activity.old_identifier ? activity.old_identifier : id;
}
value = name;
} else if (activity.field === "assignees") {
value = activity.new_value;
} else if (activity.field === "target_date") {
const date =
activity.new_value && activity.new_value !== ""
? activity.new_value
: activity.old_value;
value = renderShortDateWithYearFormat(date as string);
} else if (activity.field === "description") {
value = "description";
} else if (activity.field === "attachment") {
value = "attachment";
} else if (activity.field === "link") {
value = "link";
} else if (activity.field === "estimate_point") {
value = activity.new_value
? activity.new_value + ` Point${parseInt(activity.new_value ?? "", 10) > 1 ? "s" : ""}`
: "None";
}
if (activity.field === "comment") {
return (
<div key={activity.id} className="mt-2">
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{activity.field ? (
activity.new_value === "restore" ? (
<Icon iconName="history" className="text-sm text-custom-text-200" />
) : (
activityDetails[activity.field as keyof typeof activityDetails]?.icon
)
) : activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
<img
src={activity.actor_detail.avatar}
alt={activity.actor_detail.first_name}
height={30}
width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
/>
) : (
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
>
{activity.actor_detail.first_name.charAt(0)}
</div>
)}
<span className="absolute -bottom-0.5 -right-1 rounded-tl bg-custom-background-80 px-0.5 py-px">
<ChatBubbleLeftEllipsisIcon
className="h-3.5 w-3.5 text-custom-text-200"
aria-hidden="true"
/>
</span>
</div>
<div className="min-w-0 flex-1">
<div>
<div className="text-xs">
{activity.actor_detail.first_name}
{activity.actor_detail.is_bot ? "Bot" : " " + activity.actor_detail.last_name}
</div>
<p className="mt-0.5 text-xs text-custom-text-200">
Commented {timeAgo(activity.created_at)}
</p>
</div>
<div className="issue-comments-section p-0">
<RemirrorRichTextEditor
value={
activity.new_value && activity.new_value !== ""
? activity.new_value
: activity.old_value
}
editable={false}
noBorder
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
/>
</div>
</div>
</div>
</div>
);
}
if ("field" in activity && activity.field !== "updated_by") {
return (
<li key={activity.id}>
<div className="relative pb-1">
{activities.length > 1 && activityIdx !== activities.length - 1 ? (
<span
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80"
aria-hidden="true"
/>
) : null}
<div className="relative flex items-start space-x-2">
<>
<div>
<div className="relative px-1.5">
<div className="mt-1.5">
<div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
{activity.field ? (
activityDetails[activity.field as keyof typeof activityDetails]?.icon
) : activity.actor_detail.avatar &&
activity.actor_detail.avatar !== "" ? (
<img
src={activity.actor_detail.avatar}
alt={activity.actor_detail.first_name}
height={24}
width={24}
className="rounded-full"
/>
) : (
<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.first_name.charAt(0)}
</div>
)}
</div>
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-3">
<div className="text-xs text-custom-text-200">
{activity.field === "archived_at" && activity.new_value !== "restore" ? (
<span className="text-gray font-medium">Plane</span>
) : (
<span className="text-gray font-medium">
{activity.actor_detail.first_name}
{activity.actor_detail.is_bot
? " Bot"
: " " + activity.actor_detail.last_name}
</span>
)}
<span> {action} </span>
{activity.field !== "archived_at" && (
<span className="text-xs font-medium text-custom-text-100">
{" "}
{value}{" "}
</span>
)}
<span className="whitespace-nowrap">{timeAgo(activity.created_at)}</span>
</div>
</div>
</>
</div>
</div>
</li>
);
}
})}
</ul>
</div>
);
@@ -157,10 +157,10 @@ export const FiltersList: React.FC<Props> = ({
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<span>{member?.first_name}</span>
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
@@ -184,7 +184,7 @@ export const FiltersList: React.FC<Props> = ({
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
>
<Avatar user={member} />
<span>{member?.first_name}</span>
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
@@ -181,78 +181,85 @@ export const IssuesFilterView: React.FC = () => {
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Group by</h4>
<CustomMenu
label={
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
?.name ?? "Select"
}
className="w-28"
buttonClassName="w-full"
>
{GROUP_BY_OPTIONS.map((option) => {
if (issueView === "kanban" && option.key === null) return null;
if (option.key === "project") return null;
<div className="w-28">
<CustomMenu
label={
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
?.name ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{GROUP_BY_OPTIONS.map((option) => {
if (issueView === "kanban" && option.key === null) return null;
if (option.key === "project") return null;
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
</div>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4>
<CustomMenu
label={
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
className="w-28"
buttonClassName="w-full"
>
{ORDER_BY_OPTIONS.map((option) =>
groupByProperty === "priority" && option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
<div className="w-28">
<CustomMenu
label={
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
className="!w-full"
buttonClassName="w-full"
>
{ORDER_BY_OPTIONS.map((option) =>
groupByProperty === "priority" &&
option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
</div>
</>
)}
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4>
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters.type)
?.name ?? "Select"
}
className="w-28"
buttonClassName="w-full"
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setFilters({
type: option.key,
})
}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
<div className="w-28">
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters.type)
?.name ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setFilters({
type: option.key,
})
}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
</div>
{issueView !== "calendar" && issueView !== "spreadsheet" && (
+1 -1
View File
@@ -3,6 +3,6 @@ export * from "./modals";
export * from "./sidebar";
export * from "./theme";
export * from "./views";
export * from "./feeds";
export * from "./activity";
export * from "./reaction-selector";
export * from "./image-picker-popover";
@@ -62,7 +62,7 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
by{" "}
{link.created_by_detail.is_bot
? link.created_by_detail.first_name + " Bot"
: link.created_by_detail.email}
: link.created_by_detail.display_name}
</p>
</div>
</a>
@@ -133,9 +133,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
avatar: assignee.avatar ?? "",
first_name: assignee.first_name ?? "",
last_name: assignee.last_name ?? "",
display_name: assignee.display_name ?? "",
}}
/>
<span>{assignee.first_name}</span>
<span>{assignee.display_name}</span>
</div>
}
completed={assignee.completed_issues}
@@ -4,17 +4,15 @@ import { useTheme } from "next-themes";
import { useForm } from "react-hook-form";
// hooks
import useUser from "hooks/use-user";
// ui
import { PrimaryButton } from "components/ui";
import { ColorPickerInput } from "components/core";
// services
import userService from "services/user.service";
// helper
import { applyTheme } from "helpers/theme.helper";
// types
import { ICustomTheme } from "types";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
type Props = {
preLoadedData?: Partial<ICustomTheme> | null;
@@ -28,11 +26,14 @@ const defaultValues: ICustomTheme = {
sidebarText: "#c5c5c5",
darkPalette: false,
palette: "",
theme: "custom",
};
export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
const [darkPalette, setDarkPalette] = useState(false);
export const CustomThemeSelector: React.FC<Props> = observer(({ preLoadedData }) => {
const store: any = useMobxStore();
const { setTheme } = useTheme();
const [darkPalette, setDarkPalette] = useState(false);
const {
register,
formState: { errors, isSubmitting },
@@ -43,11 +44,14 @@ export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
} = useForm<ICustomTheme>({
defaultValues,
});
useEffect(() => {
reset({
...defaultValues,
...preLoadedData,
});
}, [preLoadedData, reset]);
const { setTheme } = useTheme();
const { mutateUser } = useUser();
const handleFormSubmit = async (formData: ICustomTheme) => {
const handleUpdateTheme = async (formData: any) => {
const payload: ICustomTheme = {
background: formData.background,
text: formData.text,
@@ -56,36 +60,17 @@ export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
sidebarText: formData.sidebarText,
darkPalette: darkPalette,
palette: `${formData.background},${formData.text},${formData.primary},${formData.sidebarBackground},${formData.sidebarText}`,
theme: "custom",
};
await userService
.updateUser({
theme: payload,
})
.then((res) => {
mutateUser((prevData) => {
if (!prevData) return prevData;
setTheme("custom");
return { ...prevData, ...res };
}, false);
setTheme("custom");
applyTheme(payload.palette, darkPalette);
})
.catch((err) => console.log(err));
return store.user
.updateCurrentUserSettings({ theme: payload })
.then((response: any) => response)
.catch((error: any) => error);
};
const handleUpdateTheme = async (formData: any) => {
await handleFormSubmit({ ...formData, darkPalette });
};
useEffect(() => {
reset({
...defaultValues,
...preLoadedData,
});
}, [preLoadedData, reset]);
return (
<form onSubmit={handleSubmit(handleUpdateTheme)}>
<div className="space-y-5">
@@ -164,4 +149,4 @@ export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
</div>
</form>
);
};
});
+33 -40
View File
@@ -1,43 +1,44 @@
import { useState, useEffect, Dispatch, SetStateAction } from "react";
// next-themes
import { useTheme } from "next-themes";
// hooks
import useUser from "hooks/use-user";
// constants
import { THEMES_OBJ } from "constants/themes";
// ui
import { CustomSelect } from "components/ui";
// types
import { ICustomTheme, IUser } from "types";
import { ICustomTheme } from "types";
import { unsetCustomCssVariables } from "helpers/theme.helper";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
type Props = {
user: IUser | undefined;
setPreLoadedData: Dispatch<SetStateAction<ICustomTheme | null>>;
setPreLoadedData: React.Dispatch<React.SetStateAction<ICustomTheme | null>>;
customThemeSelectorOptions: boolean;
setCustomThemeSelectorOptions: Dispatch<SetStateAction<boolean>>;
setCustomThemeSelectorOptions: React.Dispatch<React.SetStateAction<boolean>>;
};
export const ThemeSwitch: React.FC<Props> = ({
user,
setPreLoadedData,
customThemeSelectorOptions,
setCustomThemeSelectorOptions,
}) => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
export const ThemeSwitch: React.FC<Props> = observer(
({ setPreLoadedData, customThemeSelectorOptions, setCustomThemeSelectorOptions }) => {
const store: any = useMobxStore();
// useEffect only runs on the client, so now we can safely show the UI
useEffect(() => {
setMounted(true);
}, []);
const { user, mutateUser } = useUser();
const { theme, setTheme } = useTheme();
if (!mounted) {
return null;
}
const updateUserTheme = (newTheme: string) => {
if (!user) return;
setTheme(newTheme);
return store.user
.updateCurrentUserSettings({ theme: { ...user.theme, theme: newTheme } })
.then((response: any) => response)
.catch((error: any) => error);
};
const currentThemeObj = THEMES_OBJ.find((t) => t.value === theme);
const currentThemeObj = THEMES_OBJ.find((t) => t.value === theme);
return (
<>
return (
<CustomSelect
value={theme}
label={
@@ -84,26 +85,18 @@ export const ThemeSwitch: React.FC<Props> = ({
user.theme.palette !== ",,,,"
? user.theme.palette
: "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
theme: "custom",
});
}
if (!customThemeSelectorOptions) setCustomThemeSelectorOptions(true);
} else {
if (customThemeSelectorOptions) setCustomThemeSelectorOptions(false);
for (let i = 10; i <= 900; i >= 100 ? (i += 100) : (i += 10)) {
document.documentElement.style.removeProperty(`--color-background-${i}`);
document.documentElement.style.removeProperty(`--color-text-${i}`);
document.documentElement.style.removeProperty(`--color-border-${i}`);
document.documentElement.style.removeProperty(`--color-primary-${i}`);
document.documentElement.style.removeProperty(`--color-sidebar-background-${i}`);
document.documentElement.style.removeProperty(`--color-sidebar-text-${i}`);
document.documentElement.style.removeProperty(`--color-sidebar-border-${i}`);
}
unsetCustomCssVariables();
}
setTheme(value);
document.documentElement.style.setProperty("color-scheme", type);
updateUserTheme(value);
document.documentElement.style.setProperty("--color-scheme", type);
}}
input
width="w-full"
@@ -137,6 +130,6 @@ export const ThemeSwitch: React.FC<Props> = ({
</CustomSelect.Option>
))}
</CustomSelect>
</>
);
};
);
}
);
+29 -31
View File
@@ -21,9 +21,9 @@ import {
GanttChartView,
} from "components/core";
// ui
import { EmptyState, SecondaryButton, Spinner } from "components/ui";
import { EmptyState, Spinner } from "components/ui";
// icons
import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
import { TrashIcon } from "@heroicons/react/24/outline";
// images
import emptyIssue from "public/empty-state/issue.svg";
import emptyIssueArchive from "public/empty-state/issue-archive.svg";
@@ -39,6 +39,16 @@ type Props = {
addIssueToGroup: (groupTitle: string) => void;
disableUserActions: boolean;
dragDisabled?: boolean;
emptyState: {
title: string;
description?: string;
primaryButton?: {
icon: any;
text: string;
onClick: () => void;
};
secondaryButton?: React.ReactNode;
};
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
handleOnDragEnd: (result: DropResult) => Promise<void>;
openIssuesListModal: (() => void) | null;
@@ -53,6 +63,7 @@ export const AllViews: React.FC<Props> = ({
addIssueToGroup,
disableUserActions,
dragDisabled = false,
emptyState,
handleIssueAction,
handleOnDragEnd,
openIssuesListModal,
@@ -156,41 +167,28 @@ export const AllViews: React.FC<Props> = ({
title="Archived Issues will be shown here"
description="All the issues that have been in the completed or canceled groups for the configured period of time can be viewed here."
image={emptyIssueArchive}
buttonText="Go to Automation Settings"
onClick={() => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`);
primaryButton={{
text: "Go to Automation Settings",
onClick: () => {
router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`);
},
}}
/>
) : (
<EmptyState
title={
cycleId
? "Cycle issues will appear here"
: moduleId
? "Module issues will appear here"
: "Project issues will appear here"
}
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
title={emptyState.title}
description={emptyState.description}
image={emptyIssue}
buttonText="New Issue"
buttonIcon={<PlusIcon className="h-4 w-4" />}
secondaryButton={
cycleId || moduleId ? (
<SecondaryButton
className="flex items-center gap-1.5"
onClick={openIssuesListModal ?? (() => {})}
>
<PlusIcon className="h-4 w-4" />
Add an existing issue
</SecondaryButton>
) : null
primaryButton={
emptyState.primaryButton
? {
icon: emptyState.primaryButton.icon,
text: emptyState.primaryButton.text,
onClick: emptyState.primaryButton.onClick,
}
: undefined
}
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
}}
secondaryButton={emptyState.secondaryButton}
/>
)
) : (
@@ -81,10 +81,7 @@ export const BoardHeader: React.FC<Props> = ({
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
title =
member?.first_name && member.first_name !== ""
? `${member.first_name} ${member.last_name}`
: member?.email ?? "";
title = member?.display_name ?? "";
break;
}
@@ -149,7 +146,9 @@ export const BoardHeader: React.FC<Props> = ({
>
<span className="flex items-center">{getGroupIcon()}</span>
<h2
className="text-lg font-semibold capitalize truncate"
className={`text-lg font-semibold truncate ${
selectedGroup === "created_by" ? "" : "capitalize"
}`}
style={{
writingMode: isCollapsed ? "horizontal-tb" : "vertical-rl",
}}
@@ -24,6 +24,7 @@ import {
ViewEstimateSelect,
ViewIssueLabel,
ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect,
} from "components/issues";
// ui
@@ -221,7 +222,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
Copy issue link
</ContextMenu.Item>
<a
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
@@ -322,6 +323,14 @@ export const SingleBoardIssue: React.FC<Props> = ({
selfPositioned
/>
)}
{properties.start_date && issue.start_date && (
<ViewStartDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && issue.target_date && (
<ViewDueDateSelect
issue={issue}
@@ -21,6 +21,7 @@ import {
ViewEstimateSelect,
ViewLabelSelect,
ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect,
} from "components/issues";
// icons
@@ -192,7 +193,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
</CustomMenu>
</div>
)}
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail.id}/issues/${issue.id}`}>
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="flex w-full cursor-pointer flex-col items-start justify-center gap-1.5">
{properties.key && (
<Tooltip
@@ -230,7 +231,14 @@ export const SingleCalendarIssue: React.FC<Props> = ({
user={user}
/>
)}
{properties.start_date && issue.start_date && (
<ViewStartDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && issue.target_date && (
<ViewDueDateSelect
issue={issue}
+30 -1
View File
@@ -22,7 +22,7 @@ import { FiltersList, AllViews } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { CreateUpdateViewModal } from "components/views";
// ui
import { PrimaryButton } from "components/ui";
import { PrimaryButton, SecondaryButton } from "components/ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// helpers
@@ -515,6 +515,35 @@ export const IssuesView: React.FC<Props> = ({
selectedGroup === "labels" ||
selectedGroup === "state_detail.group"
}
emptyState={{
title: cycleId
? "Cycle issues will appear here"
: moduleId
? "Module issues will appear here"
: "Project issues will appear here",
description:
"Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done.",
primaryButton: {
icon: <PlusIcon className="h-4 w-4" />,
text: "New Issue",
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
},
},
secondaryButton:
cycleId || moduleId ? (
<SecondaryButton
className="flex items-center gap-1.5"
onClick={openIssuesListModal ?? (() => {})}
>
<PlusIcon className="h-4 w-4" />
Add an existing issue
</SecondaryButton>
) : null,
}}
handleOnDragEnd={handleOnDragEnd}
handleIssueAction={handleIssueAction}
openIssuesListModal={openIssuesListModal ? openIssuesListModal : null}
@@ -16,6 +16,7 @@ import {
ViewEstimateSelect,
ViewIssueLabel,
ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect,
} from "components/issues";
// ui
@@ -156,9 +157,9 @@ export const SingleListIssue: React.FC<Props> = ({
});
};
const singleIssuePath = isArchivedIssues
? `/${workspaceSlug}/projects/${projectId}/archived-issues/${issue.id}`
: `/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`;
const issuePath = isArchivedIssues
? `/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}`
: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`;
const isNotAllowed =
userAuth.isGuest || userAuth.isViewer || disableUserActions || isArchivedIssues;
@@ -187,7 +188,7 @@ export const SingleListIssue: React.FC<Props> = ({
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link
</ContextMenu.Item>
<a href={singleIssuePath} target="_blank" rel="noreferrer noopener">
<a href={issuePath} target="_blank" rel="noreferrer noopener">
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
</ContextMenu.Item>
@@ -202,7 +203,7 @@ export const SingleListIssue: React.FC<Props> = ({
}}
>
<div className="flex-grow cursor-pointer min-w-[200px] whitespace-nowrap overflow-hidden overflow-ellipsis">
<Link href={singleIssuePath}>
<Link href={issuePath}>
<a className="group relative flex items-center gap-2">
{properties.key && (
<Tooltip
@@ -244,6 +245,14 @@ export const SingleListIssue: React.FC<Props> = ({
isNotAllowed={isNotAllowed}
/>
)}
{properties.start_date && issue.start_date && (
<ViewStartDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && issue.target_date && (
<ViewDueDateSelect
issue={issue}
@@ -96,10 +96,7 @@ export const SingleList: React.FC<Props> = ({
break;
case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member;
title =
member?.first_name && member.first_name !== ""
? `${member.first_name} ${member.last_name}`
: member?.email ?? "";
title = member?.display_name ?? "";
break;
}
@@ -163,7 +160,11 @@ export const SingleList: React.FC<Props> = ({
<div className="flex items-center">{getGroupIcon()}</div>
)}
{selectedGroup !== null ? (
<h2 className="text-sm font-semibold capitalize leading-6 text-custom-text-100">
<h2
className={`text-sm font-semibold leading-6 text-custom-text-100 ${
selectedGroup === "created_by" ? "" : "capitalize"
}`}
>
{getGroupTitle()}
</h2>
) : (
@@ -263,7 +263,7 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
)}
</div>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="truncate text-custom-text-100 cursor-pointer w-full text-[0.825rem]">
{issue.name}
</a>
@@ -361,14 +361,14 @@ export const ActiveCycleDetails: React.FC = () => {
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.first_name}
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-background-100 capitalize">
{cycle.owned_by.first_name.charAt(0)}
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycle.owned_by.first_name}</span>
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
</div>
{cycle.assignees.length > 0 && (
@@ -88,9 +88,10 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
avatar: assignee.avatar ?? "",
first_name: assignee.first_name ?? "",
last_name: assignee.last_name ?? "",
display_name: assignee.display_name ?? "",
}}
/>
<span>{assignee.first_name}</span>
<span>{assignee.display_name}</span>
</div>
}
completed={assignee.completed_issues}
+3 -3
View File
@@ -450,14 +450,14 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
height={12}
width={12}
className="rounded-full"
alt={cycle.owned_by.first_name}
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-800 capitalize text-white">
{cycle.owned_by.first_name.charAt(0)}
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycle.owned_by.first_name}</span>
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
</div>
</div>
@@ -250,14 +250,14 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.first_name}
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.first_name.charAt(0)}
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycle.owned_by.first_name}</span>
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
</div>
</div>
<div className="flex h-5 items-center gap-2">
@@ -254,11 +254,11 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.first_name}
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.first_name.charAt(0)}
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
</div>
@@ -44,19 +44,12 @@ export const SingleUserSelect: React.FC<Props> = ({ collaborator, index, users,
);
const options = members?.map((member) => ({
value: member.member.email,
query:
(member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email) +
" " +
member.member.last_name ?? "",
value: member.member.display_name,
query: member.member.display_name ?? "",
content: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.first_name && member.member.first_name !== ""
? member.member.first_name + "(" + member.member.email + ")"
: member.member.email}
{member.member.display_name}
</div>
),
}));
@@ -3,11 +3,17 @@ import { FC } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// react-hook-form
import { useFormContext, useFieldArray, Controller } from "react-hook-form";
// hooks
import useWorkspaceMembers from "hooks/use-workspace-members";
// fetch keys
import { WORKSPACE_MEMBERS_WITH_EMAIL } from "constants/fetch-keys";
// services
import workspaceService from "services/workspace.service";
// components
import { ToggleSwitch, Input, CustomSelect, CustomSearchSelect, Avatar } from "components/ui";
@@ -30,22 +36,20 @@ export const JiraImportUsers: FC = () => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspaceMembers: members } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
const { data: members } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS_WITH_EMAIL(workspaceSlug?.toString() ?? "") : null,
workspaceSlug
? () => workspaceService.workspaceMembersWithEmail(workspaceSlug?.toString() ?? "")
: null
);
const options = members?.map((member) => ({
value: member.member.email,
query:
(member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email) +
" " +
member.member.last_name ?? "",
query: member.member.display_name ?? "",
content: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.first_name && member.member.first_name !== ""
? member.member.first_name + " (" + member.member.email + ")"
: member.member.email}
{member.member.display_name}
</div>
),
}));
@@ -42,12 +42,7 @@ export const SingleImport: React.FC<Props> = ({ service, refreshing, handleDelet
</h4>
<div className="mt-2 flex items-center gap-2 text-xs text-custom-text-200">
<span>{renderShortDateWithYearFormat(service.created_at)}</span>|
<span>
Imported by{" "}
{service.initiated_by_detail.first_name && service.initiated_by_detail.first_name !== ""
? service.initiated_by_detail.first_name + " " + service.initiated_by_detail.last_name
: service.initiated_by_detail.email}
</span>
<span>Imported by {service.initiated_by_detail.display_name}</span>
</div>
</div>
<CustomMenu ellipsis>
+10 -12
View File
@@ -8,12 +8,12 @@ import useSWR from "swr";
// services
import issuesService from "services/issues.service";
// components
import { ActivityIcon, ActivityMessage } from "components/core";
import { CommentCard } from "components/issues/comment";
// ui
import { Icon, Loader } from "components/ui";
// helpers
import { timeAgo } from "helpers/date-time.helper";
import { activityDetails } from "helpers/activity.helper";
// types
import { ICurrentUserResponse, IIssueComment } from "types";
// fetch-keys
@@ -91,11 +91,11 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
<ul role="list" className="-mb-4">
{issueActivities.map((activityItem, index) => {
// determines what type of action is performed
const message = activityItem.field
? activityDetails[activityItem.field as keyof typeof activityDetails]?.message(
activityItem
)
: "created the issue.";
const message = activityItem.field ? (
<ActivityMessage activity={activityItem} />
) : (
"created the issue."
);
if ("field" in activityItem && activityItem.field !== "updated_by") {
return (
@@ -116,14 +116,13 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
activityItem.new_value === "restore" ? (
<Icon iconName="history" className="text-sm text-custom-text-200" />
) : (
activityDetails[activityItem.field as keyof typeof activityDetails]
?.icon
<ActivityIcon activity={activityItem} />
)
) : activityItem.actor_detail.avatar &&
activityItem.actor_detail.avatar !== "" ? (
<img
src={activityItem.actor_detail.avatar}
alt={activityItem.actor_detail.first_name}
alt={activityItem.actor_detail.display_name}
height={24}
width={24}
className="rounded-full"
@@ -132,7 +131,7 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
>
{activityItem.actor_detail.first_name.charAt(0)}
{activityItem.actor_detail.display_name.charAt(0)}
</div>
)}
</div>
@@ -151,8 +150,7 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
) : (
<Link href={`/${workspaceSlug}/profile/${activityItem.actor_detail.id}`}>
<a className="text-gray font-medium">
{activityItem.actor_detail.first_name}{" "}
{activityItem.actor_detail.last_name}
{activityItem.actor_detail.display_name}
</a>
</Link>
)}{" "}
@@ -77,7 +77,7 @@ export const IssueAttachments = () => {
<Tooltip
tooltipContent={`${
people?.find((person) => person.member.id === file.updated_by)?.member
.first_name ?? ""
.display_name ?? ""
} uploaded on ${renderLongDateFormat(file.updated_at)}`}
>
<span>
@@ -70,7 +70,7 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
<img
src={comment.actor_detail.avatar}
alt={comment.actor_detail.first_name}
alt={comment.actor_detail.display_name}
height={30}
width={30}
className="grid h-7 w-7 place-items-center rounded-full border-2 border-custom-border-200"
@@ -79,7 +79,7 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
>
{comment.actor_detail.first_name.charAt(0)}
{comment.actor_detail.display_name.charAt(0)}
</div>
)}
@@ -93,8 +93,9 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
<div className="min-w-0 flex-1">
<div>
<div className="text-xs">
{comment.actor_detail.first_name}
{comment.actor_detail.is_bot ? "Bot" : " " + comment.actor_detail.last_name}
{comment.actor_detail.is_bot
? comment.actor_detail.first_name + " Bot"
: comment.actor_detail.display_name}
</div>
<p className="mt-0.5 text-xs text-custom-text-200">
Commented {timeAgo(comment.created_at)}
@@ -140,11 +141,7 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
ref={showEditorRef}
/>
<CommentReaction
workspaceSlug={comment?.workspace_detail?.slug}
projectId={comment.project}
commentId={comment.id}
/>
<CommentReaction projectId={comment.project} commentId={comment.id} />
</div>
</div>
</div>
@@ -1,5 +1,7 @@
import React from "react";
import { useRouter } from "next/router";
// hooks
import useUser from "hooks/use-user";
import useCommentReaction from "hooks/use-comment-reaction";
@@ -9,13 +11,15 @@ import { ReactionSelector } from "components/core";
import { renderEmoji } from "helpers/emoji.helper";
type Props = {
workspaceSlug?: string | string[];
projectId?: string | string[];
commentId: string;
};
export const CommentReaction: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, commentId } = props;
const { projectId, commentId } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { user } = useUser();
@@ -59,8 +59,9 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
};
const handleDeletion = async () => {
if (!workspaceSlug || !data) return;
setIsDeleteLoading(true);
if (!workspaceSlug || !projectId || !data) return;
await issueServices
.deleteIssue(workspaceSlug as string, data.project, data.id, user)
@@ -112,7 +113,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
} else {
if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
else if (moduleId) mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(data.project, params));
}
handleClose();
+33 -1
View File
@@ -75,6 +75,7 @@ const defaultValues: Partial<IIssue> = {
assignees_list: [],
labels: [],
labels_list: [],
start_date: null,
target_date: null,
};
@@ -96,6 +97,7 @@ export interface IssueFormProps {
| "priority"
| "assignee"
| "label"
| "startDate"
| "dueDate"
| "estimate"
| "parent"
@@ -239,6 +241,15 @@ export const IssueForm: FC<IssueFormProps> = ({
});
}, [getValues, projectId, reset]);
const startDate = watch("start_date");
const targetDate = watch("target_date");
const minDate = startDate ? new Date(startDate) : null;
minDate?.setDate(minDate.getDate());
const maxDate = targetDate ? new Date(targetDate) : null;
maxDate?.setDate(maxDate.getDate());
return (
<>
{projectId && (
@@ -447,13 +458,34 @@ export const IssueForm: FC<IssueFormProps> = ({
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && (
<div>
<Controller
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<IssueDateSelect
label="Start date"
maxDate={maxDate ?? undefined}
onChange={onChange}
value={value}
/>
)}
/>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
<div>
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<IssueDateSelect value={value} onChange={onChange} />
<IssueDateSelect
label="Due date"
minDate={minDate ?? undefined}
onChange={onChange}
value={value}
/>
)}
/>
</div>
+27 -19
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/router";
@@ -18,6 +18,7 @@ import useToast from "hooks/use-toast";
import useInboxView from "hooks/use-inbox-view";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
import useProjects from "hooks/use-projects";
import useMyIssues from "hooks/my-issues/use-my-issues";
// components
import { IssueForm } from "components/issues";
// types
@@ -52,6 +53,7 @@ export interface IssuesModalProps {
| "priority"
| "assignee"
| "label"
| "startDate"
| "dueDate"
| "estimate"
| "parent"
@@ -85,6 +87,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const { user } = useUser();
const { projects } = useProjects();
const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
const { setToastAlert } = useToast();
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
@@ -95,28 +99,31 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""],
};
const onClose = useCallback(() => {
handleClose();
setActiveProject(null);
}, [handleClose]);
useEffect(() => {
// if modal is closed, reset active project to null
// and return to avoid activeProject being set to some other project
if (!isOpen) {
setActiveProject(null);
return;
}
// if data is present, set active project to the project of the
// issue. This has more priority than the project in the url.
if (data && data.project) {
setActiveProject(data.project);
return;
}
// if data is not present, set active project to the project
// in the url. This has the least priority.
if (projects && projects.length > 0 && !activeProject)
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
}, [activeProject, data, projectId, projects]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleClose]);
}, [activeProject, data, projectId, projects, isOpen]);
const addIssueToCycle = async (issueId: string, cycleId: string) => {
if (!workspaceSlug || !activeProject) return;
@@ -243,6 +250,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
if (issueView === "calendar") mutate(calendarFetchKey);
if (issueView === "gantt_chart") mutate(ganttFetchKey);
if (issueView === "spreadsheet") mutate(spreadsheetFetchKey);
if (groupedIssues) mutateMyIssues();
setToastAlert({
type: "success",
@@ -263,7 +271,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
});
});
if (!createMore) handleClose();
if (!createMore) onClose();
};
const updateIssue = async (payload: Partial<IIssue>) => {
@@ -282,7 +290,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle);
if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module);
if (!createMore) handleClose();
if (!createMore) onClose();
setToastAlert({
type: "success",
@@ -320,7 +328,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={() => handleClose()}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@@ -350,7 +358,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
initialData={data ?? prePopulateData}
createMore={createMore}
setCreateMore={setCreateMore}
handleClose={handleClose}
handleClose={onClose}
projectId={activeProject ?? ""}
setActiveProject={setActiveProject}
status={data ? true : false}
@@ -146,105 +146,108 @@ export const MyIssuesViewOptions: React.FC = () => {
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Group by</h4>
<CustomMenu
label={
groupBy === "project"
? "Project"
: GROUP_BY_OPTIONS.find((option) => option.key === groupBy)?.name ??
"Select"
}
>
{GROUP_BY_OPTIONS.map((option) => {
if (issueView === "kanban" && option.key === null) return null;
if (option.key === "state" || option.key === "created_by")
return null;
<div className="w-28">
<CustomMenu
label={
groupBy === "project"
? "Project"
: GROUP_BY_OPTIONS.find((option) => option.key === groupBy)
?.name ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{GROUP_BY_OPTIONS.map((option) => {
if (issueView === "kanban" && option.key === null) return null;
if (option.key === "state" || option.key === "created_by")
return null;
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupBy(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupBy(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
</div>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4>
<CustomMenu
label={
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
>
{ORDER_BY_OPTIONS.map((option) => {
if (groupBy === "priority" && option.key === "priority") return null;
if (option.key === "sort_order") return null;
<div className="w-28">
<CustomMenu
label={
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
className="!w-full"
buttonClassName="w-full"
>
{ORDER_BY_OPTIONS.map((option) => {
if (groupBy === "priority" && option.key === "priority")
return null;
if (option.key === "sort_order") return null;
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
</div>
</>
)}
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4>
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters?.type)
?.name ?? "Select"
}
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setFilters({
type: option.key,
})
}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
<div className="w-28">
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters?.type)
?.name ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setFilters({
type: option.key,
})
}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
</div>
{issueView !== "calendar" && issueView !== "spreadsheet" && (
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty states</h4>
<ToggleSwitch value={showEmptyGroups} onChange={setShowEmptyGroups} />
<div className="w-28">
<ToggleSwitch value={showEmptyGroups} onChange={setShowEmptyGroups} />
</div>
</div>
{/* <div className="relative flex justify-end gap-x-3">
<button type="button" onClick={() => resetFilterToDefault()}>
Reset to default
</button>
<button
type="button"
className="font-medium text-custom-primary"
onClick={() => setNewFilterDefaultView()}
>
Set as default
</button>
</div> */}
</>
)}
</div>
<div className="space-y-2 py-3">
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2 text-custom-text-200">
{Object.keys(properties).map((key) => {
if (key === "estimate" && !isEstimateActive) return null;
@@ -21,6 +21,7 @@ import { orderArrayBy } from "helpers/array.helper";
import { IIssue, IIssueFilterOptions } from "types";
// fetch-keys
import { USER_ISSUES, WORKSPACE_LABELS } from "constants/fetch-keys";
import { PlusIcon } from "@heroicons/react/24/outline";
type Props = {
openIssuesListModal?: () => void;
@@ -199,7 +200,7 @@ export const MyIssuesView: React.FC<Props> = ({
[makeIssueCopy, handleEditIssue, handleDeleteIssue]
);
const filtersToDisplay = { ...filters, assignees: null, created_by: null };
const filtersToDisplay = { ...filters, assignees: null, created_by: null, subscriber: null };
const nullFilters = Object.keys(filtersToDisplay).filter(
(key) => filtersToDisplay[key as keyof IIssueFilterOptions] === null
@@ -262,6 +263,24 @@ export const MyIssuesView: React.FC<Props> = ({
addIssueToGroup={addIssueToGroup}
disableUserActions={disableUserActions}
dragDisabled={groupBy !== "priority"}
emptyState={{
title: filters.assignees
? "You don't have any issue assigned to you yet"
: filters.created_by
? "You have not created any issue yet."
: "You have not subscribed to any issue yet.",
description: "Keep track of your work in a single place.",
primaryButton: {
icon: <PlusIcon className="h-4 w-4" />,
text: "New Issue",
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
},
},
}}
handleOnDragEnd={handleOnDragEnd}
handleIssueAction={handleIssueAction}
openIssuesListModal={openIssuesListModal ? openIssuesListModal : null}
+2 -11
View File
@@ -30,20 +30,11 @@ export const IssueAssigneeSelect: React.FC<Props> = ({ projectId, value = [], on
const options = members?.map((member) => ({
value: member.member.id,
query:
(member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email) +
" " +
member.member.last_name ?? "",
query: member.member.display_name ?? "",
content: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{`${
member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email
} ${member.member.last_name ?? ""}`}
{member.member.is_bot ? member.member.first_name : member.member.display_name}
</div>
),
}));
+8 -3
View File
@@ -8,11 +8,14 @@ import DatePicker from "react-datepicker";
import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper";
type Props = {
value: string | null;
label: string;
maxDate?: Date;
minDate?: Date;
onChange: (val: string | null) => void;
value: string | null;
};
export const IssueDateSelect: React.FC<Props> = ({ value, onChange }) => (
export const IssueDateSelect: React.FC<Props> = ({ label, maxDate, minDate, onChange, value }) => (
<Popover className="relative flex items-center justify-center rounded-lg">
{({ open }) => (
<>
@@ -28,7 +31,7 @@ export const IssueDateSelect: React.FC<Props> = ({ value, onChange }) => (
) : (
<>
<CalendarDaysIcon className="h-3.5 w-3.5 flex-shrink-0" />
<span>Due Date</span>
<span>{label}</span>
</>
)}
</span>
@@ -51,6 +54,8 @@ export const IssueDateSelect: React.FC<Props> = ({ value, onChange }) => (
else onChange(renderDateFormat(val));
}}
dateFormat="dd-MM-yyyy"
minDate={minDate}
maxDate={maxDate}
inline
/>
</Popover.Panel>
@@ -41,20 +41,11 @@ export const SidebarAssigneeSelect: React.FC<Props> = ({
const options = members?.map((member) => ({
value: member.member.id,
query:
(member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email) +
" " +
member.member.last_name ?? "",
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{`${
member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email
} ${member.member.last_name ?? ""}`}
{member.member.display_name}
</div>
),
}));
+39
View File
@@ -54,6 +54,7 @@ type Props = {
| "parent"
| "blocker"
| "blocked"
| "startDate"
| "dueDate"
| "cycle"
| "module"
@@ -210,6 +211,15 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
fieldsToShow.includes("cycle") ||
fieldsToShow.includes("module");
const startDate = watchIssue("start_date");
const targetDate = watchIssue("target_date");
const minDate = startDate ? new Date(startDate) : null;
minDate?.setDate(minDate.getDate());
const maxDate = targetDate ? new Date(targetDate) : null;
maxDate?.setDate(maxDate.getDate());
const isNotAllowed = memberRole.isGuest || memberRole.isViewer;
return (
@@ -367,6 +377,34 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
disabled={uneditable}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
<CalendarDaysIcon className="h-4 w-4 flex-shrink-0" />
<p>Start date</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="start_date"
render={({ field: { value } }) => (
<CustomDatePicker
placeholder="Start date"
value={value}
onChange={(val) =>
submitChanges({
start_date: val,
})
}
className="bg-custom-background-90"
maxDate={maxDate ?? undefined}
disabled={isNotAllowed || uneditable}
/>
)}
/>
</div>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
@@ -387,6 +425,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
})
}
className="bg-custom-background-90"
minDate={minDate ?? undefined}
disabled={isNotAllowed || uneditable}
/>
)}
@@ -47,20 +47,11 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
const options = members?.map((member) => ({
value: member.member.id,
query:
(member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email) +
" " +
member.member.last_name ?? "",
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{`${
member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email
} ${member.member.last_name ?? ""}`}
{member.member.display_name}
</div>
),
}));
@@ -71,11 +62,7 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
tooltipHeading="Assignees"
tooltipContent={
issue.assignee_details.length > 0
? issue.assignee_details
.map((assignee) =>
assignee?.first_name !== "" ? assignee?.first_name : assignee?.email
)
.join(", ")
? issue.assignee_details.map((assignee) => assignee?.display_name).join(", ")
: "No Assignee"
}
>
@@ -32,9 +32,12 @@ export const ViewDueDateSelect: React.FC<Props> = ({
const { issueView } = useIssuesView();
const minDate = issue.start_date ? new Date(issue.start_date) : null;
minDate?.setDate(minDate.getDate());
return (
<Tooltip
tooltipHeading="Due Date"
tooltipHeading="Due date"
tooltipContent={
issue.target_date ? renderShortDateWithYearFormat(issue.target_date) ?? "N/A" : "N/A"
}
@@ -56,8 +59,6 @@ export const ViewDueDateSelect: React.FC<Props> = ({
partialUpdateIssue(
{
target_date: val,
priority: issue.priority,
state: issue.state,
},
issue
);
@@ -77,6 +78,7 @@ export const ViewDueDateSelect: React.FC<Props> = ({
className={`${issue?.target_date ? "w-[6.5rem]" : "w-[5rem] text-center"} ${
issueView === "kanban" ? "bg-custom-background-90" : "bg-custom-background-100"
}`}
minDate={minDate ?? undefined}
noBorder={noBorder}
disabled={isNotAllowed}
/>
@@ -1,6 +1,7 @@
export * from "./assignee";
export * from "./due-date";
export * from "./estimate";
export * from "./priority";
export * from "./state";
export * from "./label";
export * from "./priority";
export * from "./start-date";
export * from "./state";
@@ -0,0 +1,80 @@
import { useRouter } from "next/router";
// ui
import { CustomDatePicker, Tooltip } from "components/ui";
// helpers
import { findHowManyDaysLeft, renderShortDateWithYearFormat } from "helpers/date-time.helper";
// services
import trackEventServices from "services/track-event.service";
// types
import { ICurrentUserResponse, IIssue } from "types";
import useIssuesView from "hooks/use-issues-view";
type Props = {
issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
tooltipPosition?: "top" | "bottom";
noBorder?: boolean;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const ViewStartDateSelect: React.FC<Props> = ({
issue,
partialUpdateIssue,
tooltipPosition = "top",
noBorder = false,
user,
isNotAllowed,
}) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { issueView } = useIssuesView();
const maxDate = issue.target_date ? new Date(issue.target_date) : null;
maxDate?.setDate(maxDate.getDate());
return (
<Tooltip
tooltipHeading="Start date"
tooltipContent={
issue.start_date ? renderShortDateWithYearFormat(issue.start_date) ?? "N/A" : "N/A"
}
position={tooltipPosition}
>
<div className="group flex-shrink-0 relative max-w-[6.5rem]">
<CustomDatePicker
placeholder="Due date"
value={issue?.start_date}
onChange={(val) => {
partialUpdateIssue(
{
start_date: val,
},
issue
);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_DUE_DATE",
user
);
}}
className={`${issue?.start_date ? "w-[6.5rem]" : "w-[5rem] text-center"} ${
issueView === "kanban" ? "bg-custom-background-90" : "bg-custom-background-100"
}`}
maxDate={maxDate ?? undefined}
noBorder={noBorder}
disabled={isNotAllowed}
/>
</div>
</Tooltip>
);
};
@@ -4,8 +4,13 @@ import { useRouter } from "next/router";
import { mutate } from "swr";
// mobx
import { useObserver } from "mobx-react-lite";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// store
import labelStore from "store/labels";
// hooks
import useUserAuth from "hooks/use-user-auth";
// react-color
@@ -58,20 +63,17 @@ export const CreateUpdateLabelInline = forwardRef<Ref, Props>(function CreateUpd
defaultValues,
});
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
const { createLabel } = useObserver(() => ({
createLabel: labelStore.createLabel,
}));
await issuesService
.createIssueLabel(workspaceSlug as string, projectId as string, formData, user)
.then((res) => {
mutate<IIssueLabels[]>(
PROJECT_ISSUE_LABELS(projectId as string),
(prevData) => [res, ...(prevData ?? [])],
false
);
reset(defaultValues);
setLabelForm(false);
});
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
if (!workspaceSlug || !projectId || isSubmitting || !user) return;
await createLabel(workspaceSlug.toString(), projectId.toString(), formData, user).then(() => {
reset(defaultValues);
setLabelForm(false);
});
};
const handleLabelUpdate: SubmitHandler<IIssueLabels> = async (formData) => {
+3 -14
View File
@@ -32,18 +32,11 @@ export const ModuleLeadSelect: React.FC<Props> = ({ value, onChange }) => {
const options = members?.map((member) => ({
value: member.member.id,
query:
(member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email) +
" " +
member.member.last_name ?? "",
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email}
{member.member.display_name}
</div>
),
}));
@@ -62,11 +55,7 @@ export const ModuleLeadSelect: React.FC<Props> = ({ value, onChange }) => {
<UserCircleIcon className="h-4 w-4 text-custom-text-200" />
)}
{selectedOption ? (
selectedOption?.first_name && selectedOption.first_name !== "" ? (
selectedOption?.first_name
) : (
selectedOption?.email
)
selectedOption?.display_name
) : (
<span className="text-custom-text-200">Lead</span>
)}
@@ -30,18 +30,11 @@ export const ModuleMembersSelect: React.FC<Props> = ({ value, onChange }) => {
);
const options = members?.map((member) => ({
value: member.member.id,
query:
(member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email) +
" " +
member.member.last_name ?? "",
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email}
{member.member.display_name}
</div>
),
}));
@@ -33,18 +33,11 @@ export const SidebarLeadSelect: React.FC<Props> = ({ value, onChange }) => {
const options = members?.map((member) => ({
value: member.member.id,
query:
(member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email) +
" " +
member.member.last_name ?? "",
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email}
{member.member.display_name}
</div>
),
}));
@@ -64,11 +57,7 @@ export const SidebarLeadSelect: React.FC<Props> = ({ value, onChange }) => {
<div className="flex items-center gap-2">
{selectedOption && <Avatar user={selectedOption} />}
{selectedOption ? (
selectedOption?.first_name && selectedOption.first_name !== "" ? (
selectedOption?.first_name
) : (
selectedOption?.email
)
selectedOption?.display_name
) : (
<span className="text-custom-text-200">No lead</span>
)}
@@ -31,18 +31,11 @@ export const SidebarMembersSelect: React.FC<Props> = ({ value, onChange }) => {
const options = members?.map((member) => ({
value: member.member.id,
query:
(member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email) +
" " +
member.member.last_name ?? "",
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email}
{member.member.display_name}
</div>
),
}));
@@ -27,6 +27,7 @@ import { snoozeOptions } from "constants/notification";
type NotificationCardProps = {
notification: IUserNotification;
markNotificationReadStatus: (notificationId: string) => Promise<void>;
markNotificationReadStatusToggle: (notificationId: string) => Promise<void>;
markNotificationArchivedStatus: (notificationId: string) => Promise<void>;
setSelectedNotificationForSnooze: (notificationId: string) => void;
markSnoozeNotification: (notificationId: string, dateTime?: Date | undefined) => Promise<void>;
@@ -36,6 +37,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
const {
notification,
markNotificationReadStatus,
markNotificationReadStatusToggle,
markNotificationArchivedStatus,
setSelectedNotificationForSnooze,
markSnoozeNotification,
@@ -76,8 +78,8 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
) : (
<div className="w-12 h-12 bg-custom-background-80 rounded-full flex justify-center items-center">
<span className="text-custom-text-100 font-medium text-lg">
{notification.triggered_by_details.first_name?.[0] ? (
notification.triggered_by_details.first_name?.[0]?.toUpperCase()
{notification.triggered_by_details.display_name?.[0] ? (
notification.triggered_by_details.display_name?.[0]?.toUpperCase()
) : (
<Icon iconName="person" className="h-6 w-6" />
)}
@@ -87,10 +89,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
</div>
<div className="space-y-2.5 w-full overflow-hidden">
<div className="text-sm w-full break-words">
<span className="font-semibold">
{notification.triggered_by_details.first_name}{" "}
{notification.triggered_by_details.last_name}{" "}
</span>
<span className="font-semibold">{notification.triggered_by_details.display_name} </span>
{notification.data.issue_activity.field !== "comment" &&
notification.data.issue_activity.verb}{" "}
{notification.data.issue_activity.field === "comment"
@@ -159,7 +158,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
name: notification.read_at ? "Mark as unread" : "Mark as read",
icon: "chat_bubble",
onClick: () => {
markNotificationReadStatus(notification.id).then(() => {
markNotificationReadStatusToggle(notification.id).then(() => {
setToastAlert({
title: notification.read_at
? "Notification marked as unread"
@@ -1,10 +1,5 @@
import React from "react";
import { useRouter } from "next/router";
// hooks
import useWorkspaceMembers from "hooks/use-workspace-members";
// components
import { Icon, Tooltip } from "components/ui";
// helpers
@@ -44,11 +39,6 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
setSelectedTab,
} = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { isOwner, isMember } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
const notificationTabs: Array<{
label: string;
value: NotificationType;
@@ -150,59 +140,31 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) =>
</button>
) : (
<nav className="flex space-x-5 overflow-x-auto" aria-label="Tabs">
{notificationTabs.map((tab) =>
tab.value === "created" ? (
isMember || isOwner ? (
<button
type="button"
key={tab.value}
onClick={() => setSelectedTab(tab.value)}
className={`whitespace-nowrap border-b-2 pb-4 px-1 text-sm font-medium outline-none ${
{notificationTabs.map((tab) => (
<button
type="button"
key={tab.value}
onClick={() => setSelectedTab(tab.value)}
className={`whitespace-nowrap border-b-2 pb-4 px-1 text-sm font-medium outline-none ${
tab.value === selectedTab
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent text-custom-text-200"
}`}
>
{tab.label}
{tab.unreadCount && tab.unreadCount > 0 ? (
<span
className={`ml-2 rounded-full text-xs px-2 py-0.5 ${
tab.value === selectedTab
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent text-custom-text-200"
? "bg-custom-primary-100 text-white"
: "bg-custom-background-80 text-custom-text-200"
}`}
>
{tab.label}
{tab.unreadCount && tab.unreadCount > 0 ? (
<span
className={`ml-2 rounded-full text-xs px-2 py-0.5 ${
tab.value === selectedTab
? "bg-custom-primary-100 text-white"
: "bg-custom-background-80 text-custom-text-200"
}`}
>
{getNumberCount(tab.unreadCount)}
</span>
) : null}
</button>
) : null
) : (
<button
type="button"
key={tab.value}
onClick={() => setSelectedTab(tab.value)}
className={`whitespace-nowrap border-b-2 pb-4 px-1 text-sm font-medium ${
tab.value === selectedTab
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent text-custom-text-200"
}`}
>
{tab.label}
{tab.unreadCount && tab.unreadCount > 0 ? (
<span
className={`ml-2 rounded-full text-xs px-2 py-0.5 ${
tab.value === selectedTab
? "bg-custom-primary-100 text-white"
: "bg-custom-background-80 text-custom-text-200"
}`}
>
{getNumberCount(tab.unreadCount)}
</span>
) : null}
</button>
)
)}
{getNumberCount(tab.unreadCount)}
</span>
) : null}
</button>
))}
</nav>
)}
</div>
@@ -21,8 +21,12 @@ import { NotificationsOutlined } from "@mui/icons-material";
import emptyNotification from "public/empty-state/notification.svg";
// helpers
import { getNumberCount } from "helpers/string.helper";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
export const NotificationPopover = () => {
const store: any = useMobxStore();
const {
notifications,
archived,
@@ -38,6 +42,7 @@ export const NotificationPopover = () => {
notificationMutate,
markNotificationArchivedStatus,
markNotificationReadStatus,
markNotificationAsRead,
markSnoozeNotification,
notificationCount,
totalNotificationCount,
@@ -76,17 +81,17 @@ export const NotificationPopover = () => {
tooltipContent="Notifications"
position="right"
className="ml-2"
disabled={!sidebarCollapse}
disabled={!store?.theme?.sidebarCollapsed}
>
<Popover.Button
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
isActive
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
} ${sidebarCollapse ? "justify-center" : ""}`}
} ${store?.theme?.sidebarCollapsed ? "justify-center" : ""}`}
>
<NotificationsOutlined fontSize="small" />
{sidebarCollapse ? null : <span>Notifications</span>}
{store?.theme?.sidebarCollapsed ? null : <span>Notifications</span>}
{totalNotificationCount && totalNotificationCount > 0 ? (
<span className="ml-auto bg-custom-primary-300 rounded-full text-xs text-white px-1.5">
{getNumberCount(totalNotificationCount)}
@@ -128,7 +133,8 @@ export const NotificationPopover = () => {
key={notification.id}
notification={notification}
markNotificationArchivedStatus={markNotificationArchivedStatus}
markNotificationReadStatus={markNotificationReadStatus}
markNotificationReadStatus={markNotificationAsRead}
markNotificationReadStatusToggle={markNotificationReadStatus}
setSelectedNotificationForSnooze={setSelectedNotificationForSnooze}
markSnoozeNotification={markSnoozeNotification}
/>
@@ -56,13 +56,15 @@ export const RecentPagesList: React.FC<TPagesListProps> = ({ viewType }) => {
title="Have your thoughts in place"
description="You can think of Pages as an AI-powered notepad."
image={emptyPage}
buttonText="New Page"
buttonIcon={<PlusIcon className="h-4 w-4" />}
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "d",
});
document.dispatchEvent(e);
primaryButton={{
icon: <PlusIcon className="h-4 w-4" />,
text: "New Page",
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "d",
});
document.dispatchEvent(e);
},
}}
/>
)
+9 -7
View File
@@ -260,13 +260,15 @@ export const PagesView: React.FC<Props> = ({ pages, viewType }) => {
title="Have your thoughts in place"
description="You can think of Pages as an AI-powered notepad."
image={emptyPage}
buttonText="New Page"
buttonIcon={<PlusIcon className="h-4 w-4" />}
onClick={() => {
const e = new KeyboardEvent("keydown", {
key: "d",
});
document.dispatchEvent(e);
primaryButton={{
icon: <PlusIcon className="h-4 w-4" />,
text: "New Page",
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "d",
});
document.dispatchEvent(e);
},
}}
/>
)}
@@ -162,7 +162,7 @@ export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
position="top-right"
tooltipContent={`Created by ${
people?.find((person) => person.member.id === page.created_by)?.member
.first_name ?? ""
.display_name ?? ""
} on ${renderLongDateFormat(`${page.created_at}`)}`}
>
<span>
@@ -161,7 +161,7 @@ export const SinglePageListItem: React.FC<TSingleStatProps> = ({
position="top-right"
tooltipContent={`Created by ${
people?.find((person) => person.member.id === page.created_by)?.member
.first_name ?? ""
.display_name ?? ""
} on ${renderLongDateFormat(`${page.created_at}`)}`}
>
<span>
@@ -1,14 +1,16 @@
import { useRouter } from "next/router";
import Link from "next/link";
import useSWR from "swr";
// services
import userService from "services/user.service";
// components
import { ActivityMessage } from "components/core";
// ui
import { Icon, Loader } from "components/ui";
import { ProfileEmptyState, Icon, Loader } from "components/ui";
// image
import recentActivityEmptyState from "public/empty-state/recent_activity.svg";
// helpers
import { activityDetails } from "helpers/activity.helper";
import { timeAgo } from "helpers/date-time.helper";
// fetch-keys
import { USER_PROFILE_ACTIVITY } from "constants/fetch-keys";
@@ -31,51 +33,59 @@ export const ProfileActivity = () => {
<h3 className="text-lg font-medium">Recent Activity</h3>
<div className="border border-custom-border-100 rounded p-6">
{userProfileActivity ? (
<div className="space-y-5">
{userProfileActivity.results.map((activity) => (
<div key={activity.id} className="flex gap-3">
<div className="flex-shrink-0">
{activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
<img
src={activity.actor_detail.avatar}
alt={activity.actor_detail.first_name}
height={24}
width={24}
className="rounded"
/>
) : (
<div className="grid h-6 w-6 place-items-center rounded border-2 bg-gray-700 text-xs text-white">
{activity.actor_detail.first_name.charAt(0)}
</div>
)}
</div>
<div className="-mt-1 w-4/5 break-words">
<p className="text-sm text-custom-text-200">
<span className="font-medium text-custom-text-100">
{activity.actor_detail.first_name} {activity.actor_detail.last_name}{" "}
</span>
{activity.field ? (
activityDetails[activity.field]?.message(activity as any)
userProfileActivity.results.length > 0 ? (
<div className="space-y-5">
{userProfileActivity.results.map((activity) => (
<div key={activity.id} className="flex gap-3">
<div className="flex-shrink-0">
{activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
<img
src={activity.actor_detail.avatar}
alt={activity.actor_detail.display_name}
height={24}
width={24}
className="rounded"
/>
) : (
<span>
created this{" "}
<a
href={`/${activity.workspace_detail.slug}/projects/${activity.project}/issues/${activity.issue}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
Issue
<Icon iconName="launch" className="!text-xs" />
</a>
</span>
<div className="grid h-6 w-6 place-items-center rounded border-2 bg-gray-700 text-xs text-white">
{activity.actor_detail.display_name?.charAt(0)}
</div>
)}
</p>
<p className="text-xs text-custom-text-200">{timeAgo(activity.created_at)}</p>
</div>
<div className="-mt-1 w-4/5 break-words">
<p className="text-sm text-custom-text-200">
<span className="font-medium text-custom-text-100">
{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="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
Issue
<Icon iconName="launch" className="!text-xs" />
</a>
</span>
)}
</p>
<p className="text-xs text-custom-text-200">{timeAgo(activity.created_at)}</p>
</div>
</div>
</div>
))}
</div>
))}
</div>
) : (
<ProfileEmptyState
title="No Data yet"
description="We couldnt find data. Kindly view your inputs"
image={recentActivityEmptyState}
/>
)
) : (
<Loader className="space-y-5">
<Loader.Item height="40px" />
@@ -1,5 +1,7 @@
// ui
import { BarGraph, Loader } from "components/ui";
import { BarGraph, ProfileEmptyState, Loader } from "components/ui";
// image
import priorityGraph from "public/empty-state/priority_graph.svg";
// helpers
import { capitalizeFirstLetter } from "helpers/string.helper";
// types
@@ -14,51 +16,61 @@ export const ProfilePriorityDistribution: React.FC<Props> = ({ userProfile }) =>
<h3 className="text-lg font-medium">Issues by Priority</h3>
{userProfile ? (
<div className="border border-custom-border-100 rounded">
<BarGraph
data={userProfile.priority_distribution.map((priority) => ({
priority: capitalizeFirstLetter(priority.priority ?? "None"),
value: priority.priority_count,
}))}
height="300px"
indexBy="priority"
keys={["value"]}
borderRadius={4}
padding={0.7}
customYAxisTickValues={userProfile.priority_distribution.map((p) => p.priority_count)}
tooltip={(datum) => (
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: datum.color,
}}
/>
<span className="font-medium text-custom-text-200">{datum.data.priority}:</span>
<span>{datum.value}</span>
</div>
)}
colors={(datum) => {
if (datum.data.priority === "Urgent") return "#991b1b";
else if (datum.data.priority === "High") return "#ef4444";
else if (datum.data.priority === "Medium") return "#f59e0b";
else if (datum.data.priority === "Low") return "#16a34a";
else return "#e5e5e5";
}}
theme={{
axis: {
domain: {
{userProfile.priority_distribution.length > 0 ? (
<BarGraph
data={userProfile.priority_distribution.map((priority) => ({
priority: capitalizeFirstLetter(priority.priority ?? "None"),
value: priority.priority_count,
}))}
height="300px"
indexBy="priority"
keys={["value"]}
borderRadius={4}
padding={0.7}
customYAxisTickValues={userProfile.priority_distribution.map((p) => p.priority_count)}
tooltip={(datum) => (
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: datum.color,
}}
/>
<span className="font-medium text-custom-text-200">{datum.data.priority}:</span>
<span>{datum.value}</span>
</div>
)}
colors={(datum) => {
if (datum.data.priority === "Urgent") return "#991b1b";
else if (datum.data.priority === "High") return "#ef4444";
else if (datum.data.priority === "Medium") return "#f59e0b";
else if (datum.data.priority === "Low") return "#16a34a";
else return "#e5e5e5";
}}
theme={{
axis: {
domain: {
line: {
stroke: "transparent",
},
},
},
grid: {
line: {
stroke: "transparent",
},
},
},
grid: {
line: {
stroke: "transparent",
},
},
}}
/>
}}
/>
) : (
<div className="p-7">
<ProfileEmptyState
title="No Data yet"
description="Create issues to view the them by priority in the graph for better analysis."
image={priorityGraph}
/>
</div>
)}
</div>
) : (
<div className="grid place-items-center p-7">
@@ -1,5 +1,7 @@
// ui
import { PieGraph } from "components/ui";
import { ProfileEmptyState, PieGraph } from "components/ui";
// image
import stateGraph from "public/empty-state/state_graph.svg";
// types
import { IUserProfileData, IUserStateDistribution } from "types";
// constants
@@ -17,64 +19,72 @@ export const ProfileStateDistribution: React.FC<Props> = ({ stateDistribution, u
<div className="space-y-2">
<h3 className="text-lg font-medium">Issues by State</h3>
<div className="border border-custom-border-100 rounded p-7">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<div>
<PieGraph
data={
userProfile.state_distribution.map((group) => ({
id: group.state_group,
label: group.state_group,
value: group.state_count,
color: STATE_GROUP_COLORS[group.state_group],
})) ?? []
}
height="250px"
innerRadius={0.6}
cornerRadius={5}
padAngle={2}
enableArcLabels
arcLabelsTextColor="#000000"
enableArcLinkLabels={false}
activeInnerRadiusOffset={5}
colors={(datum) => datum.data.color}
tooltip={(datum) => (
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 p-2 text-xs">
<span className="text-custom-text-200 capitalize">
{datum.datum.label} issues:
</span>{" "}
{datum.datum.value}
</div>
)}
margin={{
top: 32,
right: 0,
bottom: 32,
left: 0,
}}
/>
</div>
<div className="flex items-center">
<div className="space-y-4 w-full">
{stateDistribution.map((group) => (
<div
key={group.state_group}
className="flex items-center justify-between gap-2 text-xs"
>
<div className="flex items-center gap-1.5">
<div
className="h-2.5 w-2.5 rounded-sm"
style={{
backgroundColor: STATE_GROUP_COLORS[group.state_group],
}}
/>
<div className="capitalize whitespace-nowrap">{group.state_group}</div>
{userProfile.state_distribution.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<div>
<PieGraph
data={
userProfile.state_distribution.map((group) => ({
id: group.state_group,
label: group.state_group,
value: group.state_count,
color: STATE_GROUP_COLORS[group.state_group],
})) ?? []
}
height="250px"
innerRadius={0.6}
cornerRadius={5}
padAngle={2}
enableArcLabels
arcLabelsTextColor="#000000"
enableArcLinkLabels={false}
activeInnerRadiusOffset={5}
colors={(datum) => datum.data.color}
tooltip={(datum) => (
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 p-2 text-xs">
<span className="text-custom-text-200 capitalize">
{datum.datum.label} issues:
</span>{" "}
{datum.datum.value}
</div>
<div>{group.state_count}</div>
</div>
))}
)}
margin={{
top: 32,
right: 0,
bottom: 32,
left: 0,
}}
/>
</div>
<div className="flex items-center">
<div className="space-y-4 w-full">
{stateDistribution.map((group) => (
<div
key={group.state_group}
className="flex items-center justify-between gap-2 text-xs"
>
<div className="flex items-center gap-1.5">
<div
className="h-2.5 w-2.5 rounded-sm"
style={{
backgroundColor: STATE_GROUP_COLORS[group.state_group],
}}
/>
<div className="capitalize whitespace-nowrap">{group.state_group}</div>
</div>
<div>{group.state_count}</div>
</div>
))}
</div>
</div>
</div>
</div>
) : (
<ProfileEmptyState
title="No Data yet"
description="Create issues to view the them by states in the graph for better analysis."
image={stateGraph}
/>
)}
</div>
</div>
);
@@ -172,106 +172,109 @@ export const ProfileIssuesViewOptions: React.FC = () => {
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Group by</h4>
<CustomMenu
label={
groupByProperty === "project"
? "Project"
: GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
?.name ?? "Select"
}
>
{GROUP_BY_OPTIONS.map((option) => {
if (issueView === "kanban" && option.key === null) return null;
if (option.key === "state" || option.key === "created_by")
return null;
<div className="w-28">
<CustomMenu
label={
groupByProperty === "project"
? "Project"
: GROUP_BY_OPTIONS.find(
(option) => option.key === groupByProperty
)?.name ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{GROUP_BY_OPTIONS.map((option) => {
if (issueView === "kanban" && option.key === null) return null;
if (option.key === "state" || option.key === "created_by")
return null;
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
</div>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4>
<CustomMenu
label={
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
>
{ORDER_BY_OPTIONS.map((option) => {
if (groupByProperty === "priority" && option.key === "priority")
return null;
if (option.key === "sort_order") return null;
<div className="w-28">
<CustomMenu
label={
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
className="!w-full"
buttonClassName="w-full"
>
{ORDER_BY_OPTIONS.map((option) => {
if (groupByProperty === "priority" && option.key === "priority")
return null;
if (option.key === "sort_order") return null;
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
</div>
</>
)}
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4>
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters?.type)
?.name ?? "Select"
}
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setFilters({
type: option.key,
})
}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
<div className="w-28">
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters?.type)
?.name ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setFilters({
type: option.key,
})
}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
</div>
{issueView !== "calendar" && issueView !== "spreadsheet" && (
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty states</h4>
<ToggleSwitch value={showEmptyGroups} onChange={setShowEmptyGroups} />
<div className="w-28">
<ToggleSwitch value={showEmptyGroups} onChange={setShowEmptyGroups} />
</div>
</div>
{/* <div className="relative flex justify-end gap-x-3">
<button type="button" onClick={() => resetFilterToDefault()}>
Reset to default
</button>
<button
type="button"
className="font-medium text-custom-primary"
onClick={() => setNewFilterDefaultView()}
>
Set as default
</button>
</div> */}
</>
)}
</div>
<div className="space-y-2 py-3">
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2 text-custom-text-200">
{Object.keys(properties).map((key) => {
if (key === "estimate" && !isEstimateActive) return null;
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useRouter } from "next/router";
@@ -8,6 +8,7 @@ import useSWR from "swr";
import { DropResult } from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
import userService from "services/user.service";
// hooks
import useProfileIssues from "hooks/use-profile-issues";
import useUser from "hooks/use-user";
@@ -19,7 +20,7 @@ import { orderArrayBy } from "helpers/array.helper";
// types
import { IIssue, IIssueFilterOptions } from "types";
// fetch-keys
import { WORKSPACE_LABELS } from "constants/fetch-keys";
import { USER_PROFILE_PROJECT_SEGREGATION, WORKSPACE_LABELS } from "constants/fetch-keys";
export const ProfileIssuesView = () => {
// create issue modal
@@ -60,6 +61,16 @@ export const ProfileIssuesView = () => {
params,
} = useProfileIssues(workspaceSlug?.toString(), userId?.toString());
const { data: profileData } = useSWR(
workspaceSlug && userId
? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString())
: null,
workspaceSlug && userId
? () =>
userService.getUserProfileProjectsSegregation(workspaceSlug.toString(), userId.toString())
: null
);
const { data: labels } = useSWR(
workspaceSlug && (filters?.labels ?? []).length > 0
? WORKSPACE_LABELS(workspaceSlug.toString())
@@ -140,10 +151,26 @@ export const ProfileIssuesView = () => {
]
);
const addIssueToGroup = useCallback((groupTitle: string) => {
setCreateIssueModal(true);
return;
}, []);
const addIssueToGroup = useCallback(
(groupTitle: string) => {
setCreateIssueModal(true);
let preloadedValue: string | string[] = groupTitle;
if (groupByProperty === "labels") {
if (groupTitle === "None") preloadedValue = [];
else preloadedValue = [groupTitle];
}
if (groupByProperty)
setPreloadedData({
[groupByProperty]: preloadedValue,
actionType: "createIssue",
});
else setPreloadedData({ actionType: "createIssue" });
},
[setCreateIssueModal, setPreloadedData, groupByProperty]
);
const addIssueToDate = useCallback(
(date: string) => {
@@ -250,6 +277,13 @@ export const ProfileIssuesView = () => {
addIssueToGroup={addIssueToGroup}
disableUserActions={false}
dragDisabled={groupByProperty !== "priority"}
emptyState={{
title: router.pathname.includes("assigned")
? `Issues assigned to ${profileData?.user_data.display_name} will appear here`
: router.pathname.includes("created")
? `Issues created by ${profileData?.user_data.display_name} will appear here`
: `Issues subscribed by ${profileData?.user_data.display_name} will appear here`,
}}
handleOnDragEnd={handleOnDragEnd}
handleIssueAction={handleIssueAction}
openIssuesListModal={null}
+8 -8
View File
@@ -86,29 +86,29 @@ export const ProfileSidebar = () => {
userProjectsData.user_data.cover_image ??
"https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"
}
alt={userProjectsData.user_data.first_name}
alt={userProjectsData.user_data.display_name}
className="h-32 w-full object-cover"
/>
<div className="absolute -bottom-[26px] left-5 h-[52px] w-[52px] rounded">
{userProjectsData.user_data.avatar && userProjectsData.user_data.avatar !== "" ? (
<img
src={userProjectsData.user_data.avatar}
alt={userProjectsData.user_data.first_name}
alt={userProjectsData.user_data.display_name}
className="rounded"
/>
) : (
<div className="bg-custom-background-90 text-custom-text-100">
{userProjectsData.user_data.first_name[0]}
<div className="bg-custom-background-90 flex justify-center items-center w-[52px] h-[52px] rounded text-custom-text-100">
{userProjectsData.user_data.display_name?.[0]}
</div>
)}
</div>
</div>
<div className="px-5">
<div className="mt-[38px]">
<h4 className="text-lg font-semibold">
{userProjectsData.user_data.first_name} {userProjectsData.user_data.last_name}
</h4>
<h6 className="text-custom-text-200 text-sm">{userProjectsData.user_data.email}</h6>
<h4 className="text-lg font-semibold">{userProjectsData.user_data.display_name}</h4>
<h6 className="text-custom-text-200 text-sm">
{userProjectsData.user_data.display_name}
</h6>
</div>
<div className="mt-6 space-y-5">
{userDetails.map((detail) => (
@@ -67,13 +67,13 @@ const ConfirmProjectMemberRemove: React.FC<Props> = ({ isOpen, onClose, data, ha
as="h3"
className="text-lg font-medium leading-6 text-custom-text-100"
>
Remove {data?.email}?
Remove {data?.display_name}?
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-custom-text-200">
Are you sure you want to remove member-{" "}
<span className="font-bold">{data?.email}</span>? They will no longer have
access to this project. This action cannot be undone.
<span className="font-bold">{data?.display_name}</span>? They will no
longer have access to this project. This action cannot be undone.
</p>
</div>
</div>
@@ -47,7 +47,7 @@ type Props = {
const defaultValues: Partial<IProject> = {
cover_image:
"https://images.unsplash.com/photo-1575116464504-9e7652fddcb3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwyODUyNTV8MHwxfHNlYXJjaHwxOHx8cGxhbmV8ZW58MHx8fHwxNjgxNDY4NTY5&ixlib=rb-4.0.3&q=80&w=1080",
"https://images.unsplash.com/photo-1531045535792-b515d59c3d1f?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=870&q=80",
description: "",
emoji_and_icon: getRandomEmoji(),
identifier: "",
@@ -163,20 +163,11 @@ export const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen, user })
const options = workspaceMembers?.map((member) => ({
value: member.member.id,
query:
(member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email) +
" " +
member.member.last_name ?? "",
query: member.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar user={member.member} />
{`${
member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email
} ${member.member.last_name ?? ""}`}
{member.member.display_name}
</div>
),
}));
@@ -213,7 +204,7 @@ export const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen, user })
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="transform rounded-lg bg-custom-background-100 text-left shadow-xl transition-all p-3 w-full sm:w-3/5 lg:w-1/2 xl:w-2/5">
<div className="group relative h-36 w-full rounded-lg bg-custom-background-80">
<div className="group relative h-44 w-full rounded-lg bg-custom-background-80">
{watch("cover_image") !== null && (
<img
src={watch("cover_image")!}
@@ -310,7 +301,7 @@ export const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen, user })
<TextArea
id="description"
name="description"
className="text-sm !h-[8rem]"
className="text-sm !h-24"
placeholder="Description..."
error={errors.description}
register={register}
@@ -327,6 +318,7 @@ export const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen, user })
<CustomSelect
value={value}
onChange={onChange}
buttonClassName="border-[0.5px] !px-2 shadow-md"
label={
<div className="flex items-center gap-2 -mb-0.5 py-1">
{currentNetwork ? (
@@ -369,15 +361,13 @@ export const CreateProjectModal: React.FC<Props> = ({ isOpen, setIsOpen, user })
value={value}
onChange={onChange}
options={options}
buttonClassName="!px-2 shadow-md"
label={
<div className="flex items-center justify-center gap-2 py-[1px]">
{value ? (
<>
<Avatar user={selectedMember?.member} />
<span>
{selectedMember?.member.first_name}{" "}
{selectedMember?.member.last_name}
</span>
<span>{selectedMember?.member.display_name} </span>
<span onClick={() => onChange(null)}>
<Icon
iconName="close"
@@ -82,7 +82,7 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
});
const uninvitedPeople = people?.filter((person) => {
const isInvited = members?.find((member) => member.email === person.member.email);
const isInvited = members?.find((member) => member.display_name === person.member.display_name);
return !isInvited;
});
@@ -136,11 +136,11 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
const options = uninvitedPeople?.map((person) => ({
value: person.member.id,
query: person.member.email,
query: person.member.display_name,
content: (
<div className="flex items-center gap-2">
<Avatar user={person.member} />
{person.member.email}
{person.member.display_name}
</div>
),
}));
@@ -209,7 +209,10 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
people?.find((p) => p.member.id === value)?.member
}
/>
{people?.find((p) => p.member.id === value)?.member.email}
{
people?.find((p) => p.member.id === value)?.member
.display_name
}
</div>
) : (
<div>Select co-worker&rsquo;s email</div>

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