merge conflicts resolved

This commit is contained in:
sriramveeraghanta 2023-08-16 13:00:58 +05:30
commit 9003c58d89
360 changed files with 13916 additions and 7344 deletions

View File

@ -21,6 +21,8 @@ NEXT_PUBLIC_TRACK_EVENTS=0
NEXT_PUBLIC_SLACK_CLIENT_ID="" NEXT_PUBLIC_SLACK_CLIENT_ID=""
# For Telemetry, set it to "app.plane.so" # For Telemetry, set it to "app.plane.so"
NEXT_PUBLIC_PLAUSIBLE_DOMAIN="" NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
# public boards deploy url
NEXT_PUBLIC_DEPLOY_URL=""
# Backend # Backend
# Debug value for api server use it as 0 for production use # Debug value for api server use it as 0 for production use

4
.gitignore vendored
View File

@ -70,4 +70,6 @@ package-lock.json
# lock files # lock files
package-lock.json package-lock.json
pnpm-lock.yaml pnpm-lock.yaml
pnpm-workspace.yaml pnpm-workspace.yaml
.npmrc

View File

@ -1,4 +1,4 @@
import os, sys import os, sys, random, string
import uuid import uuid
sys.path.append("/code") sys.path.append("/code")
@ -19,9 +19,9 @@ def populate():
user = User.objects.create(email=default_email, username=uuid.uuid4().hex) user = User.objects.create(email=default_email, username=uuid.uuid4().hex)
user.set_password(default_password) user.set_password(default_password)
user.save() user.save()
print("User created") print(f"User created with an email: {default_email}")
else:
print("Success") print(f"User already exists with the default email: {default_email}")
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,10 +1,5 @@
from .base import BaseSerializer from .base import BaseSerializer
from .people import ( from .user import UserSerializer, UserLiteSerializer, ChangePasswordSerializer, ResetPasswordSerializer, UserAdminLiteSerializer
ChangePasswordSerializer,
ResetPasswordSerializer,
TokenSerializer,
)
from .user import UserSerializer, UserLiteSerializer
from .workspace import ( from .workspace import (
WorkSpaceSerializer, WorkSpaceSerializer,
WorkSpaceMemberSerializer, WorkSpaceMemberSerializer,
@ -12,6 +7,7 @@ from .workspace import (
WorkSpaceMemberInviteSerializer, WorkSpaceMemberInviteSerializer,
WorkspaceLiteSerializer, WorkspaceLiteSerializer,
WorkspaceThemeSerializer, WorkspaceThemeSerializer,
WorkspaceMemberAdminSerializer,
) )
from .project import ( from .project import (
ProjectSerializer, ProjectSerializer,
@ -22,6 +18,8 @@ from .project import (
ProjectFavoriteSerializer, ProjectFavoriteSerializer,
ProjectLiteSerializer, ProjectLiteSerializer,
ProjectMemberLiteSerializer, ProjectMemberLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
) )
from .state import StateSerializer, StateLiteSerializer from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer from .view import IssueViewSerializer, IssueViewFavoriteSerializer
@ -45,6 +43,7 @@ from .issue import (
IssueSubscriberSerializer, IssueSubscriberSerializer,
IssueReactionSerializer, IssueReactionSerializer,
CommentReactionSerializer, CommentReactionSerializer,
IssueVoteSerializer,
) )
from .module import ( from .module import (
@ -82,3 +81,5 @@ from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSeriali
from .analytic import AnalyticViewSerializer from .analytic import AnalyticViewSerializer
from .notification import NotificationSerializer from .notification import NotificationSerializer
from .exporter import ExporterHistorySerializer

View File

@ -41,6 +41,7 @@ class CycleSerializer(BaseSerializer):
{ {
"avatar": assignee.avatar, "avatar": assignee.avatar,
"first_name": assignee.first_name, "first_name": assignee.first_name,
"display_name": assignee.display_name,
"id": assignee.id, "id": assignee.id,
} }
for issue_cycle in obj.issue_cycle.all() for issue_cycle in obj.issue_cycle.all()

View File

@ -0,0 +1,26 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import ExporterHistory
from .user import UserLiteSerializer
class ExporterHistorySerializer(BaseSerializer):
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
class Meta:
model = ExporterHistory
fields = [
"id",
"created_at",
"updated_at",
"project",
"provider",
"status",
"url",
"initiated_by",
"initiated_by_detail",
"token",
"created_by",
"updated_by",
]
read_only_fields = fields

View File

@ -31,6 +31,7 @@ from plane.db.models import (
IssueAttachment, IssueAttachment,
IssueReaction, IssueReaction,
CommentReaction, CommentReaction,
IssueVote,
) )
@ -111,6 +112,11 @@ class IssueCreateSerializer(BaseSerializer):
"updated_at", "updated_at",
] ]
def validate(self, data):
if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None):
raise serializers.ValidationError("Start date cannot exceed target date")
return data
def create(self, validated_data): def create(self, validated_data):
blockers = validated_data.pop("blockers_list", None) blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None) assignees = validated_data.pop("assignees_list", None)
@ -549,6 +555,14 @@ class CommentReactionSerializer(BaseSerializer):
class IssueVoteSerializer(BaseSerializer):
class Meta:
model = IssueVote
fields = ["issue", "vote", "workspace_id", "project_id", "actor"]
read_only_fields = fields
class IssueCommentSerializer(BaseSerializer): class IssueCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor") actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue") issue_detail = IssueFlatSerializer(read_only=True, source="issue")
@ -568,6 +582,7 @@ class IssueCommentSerializer(BaseSerializer):
"updated_by", "updated_by",
"created_at", "created_at",
"updated_at", "updated_at",
"access",
] ]

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__"

View File

@ -7,13 +7,14 @@ from rest_framework import serializers
# Module imports # Module imports
from .base import BaseSerializer from .base import BaseSerializer
from plane.api.serializers.workspace import WorkSpaceSerializer, WorkspaceLiteSerializer 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 ( from plane.db.models import (
Project, Project,
ProjectMember, ProjectMember,
ProjectMemberInvite, ProjectMemberInvite,
ProjectIdentifier, ProjectIdentifier,
ProjectFavorite, ProjectFavorite,
ProjectDeployBoard,
) )
@ -80,7 +81,14 @@ class ProjectSerializer(BaseSerializer):
class ProjectLiteSerializer(BaseSerializer): class ProjectLiteSerializer(BaseSerializer):
class Meta: class Meta:
model = Project model = Project
fields = ["id", "identifier", "name"] fields = [
"id",
"identifier",
"name",
"cover_image",
"icon_prop",
"emoji",
]
read_only_fields = fields read_only_fields = fields
@ -94,6 +102,8 @@ class ProjectDetailSerializer(BaseSerializer):
total_modules = serializers.IntegerField(read_only=True) total_modules = serializers.IntegerField(read_only=True)
is_member = serializers.BooleanField(read_only=True) is_member = serializers.BooleanField(read_only=True)
sort_order = serializers.FloatField(read_only=True) sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True)
is_deployed = serializers.BooleanField(read_only=True)
class Meta: class Meta:
model = Project model = Project
@ -110,6 +120,16 @@ class ProjectMemberSerializer(BaseSerializer):
fields = "__all__" 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): class ProjectMemberInviteSerializer(BaseSerializer):
project = ProjectLiteSerializer(read_only=True) project = ProjectLiteSerializer(read_only=True)
workspace = WorkspaceLiteSerializer(read_only=True) workspace = WorkspaceLiteSerializer(read_only=True)
@ -137,8 +157,6 @@ class ProjectFavoriteSerializer(BaseSerializer):
] ]
class ProjectMemberLiteSerializer(BaseSerializer): class ProjectMemberLiteSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True) member = UserLiteSerializer(read_only=True)
is_subscribed = serializers.BooleanField(read_only=True) is_subscribed = serializers.BooleanField(read_only=True)
@ -147,3 +165,16 @@ class ProjectMemberLiteSerializer(BaseSerializer):
model = ProjectMember model = ProjectMember
fields = ["member", "id", "is_subscribed"] fields = ["member", "id", "is_subscribed"]
read_only_fields = fields read_only_fields = fields
class ProjectDeployBoardSerializer(BaseSerializer):
project_details = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
class Meta:
model = ProjectDeployBoard
fields = "__all__"
read_only_fields = [
"workspace",
"project" "anchor",
]

View File

@ -1,3 +1,6 @@
# Third party imports
from rest_framework import serializers
# Module import # Module import
from .base import BaseSerializer from .base import BaseSerializer
from plane.db.models import User from plane.db.models import User
@ -37,11 +40,50 @@ class UserLiteSerializer(BaseSerializer):
"id", "id",
"first_name", "first_name",
"last_name", "last_name",
"email",
"avatar", "avatar",
"is_bot", "is_bot",
"display_name",
] ]
read_only_fields = [ read_only_fields = [
"id", "id",
"is_bot", "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)

View File

@ -3,7 +3,7 @@ from rest_framework import serializers
# Module imports # Module imports
from .base import BaseSerializer from .base import BaseSerializer
from .user import UserLiteSerializer from .user import UserLiteSerializer, UserAdminLiteSerializer
from plane.db.models import ( from plane.db.models import (
User, User,
@ -33,10 +33,30 @@ class WorkSpaceSerializer(BaseSerializer):
"owner", "owner",
] ]
class WorkspaceLiteSerializer(BaseSerializer):
class Meta:
model = Workspace
fields = [
"name",
"slug",
"id",
]
read_only_fields = fields
class WorkSpaceMemberSerializer(BaseSerializer): class WorkSpaceMemberSerializer(BaseSerializer):
member = UserLiteSerializer(read_only=True) 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: class Meta:
model = WorkspaceMember model = WorkspaceMember
@ -101,17 +121,6 @@ class TeamSerializer(BaseSerializer):
return super().update(instance, validated_data) 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 WorkspaceThemeSerializer(BaseSerializer):
class Meta: class Meta:
model = WorkspaceTheme model = WorkspaceTheme

View File

@ -32,6 +32,7 @@ from plane.api.views import (
InviteWorkspaceEndpoint, InviteWorkspaceEndpoint,
JoinWorkspaceEndpoint, JoinWorkspaceEndpoint,
WorkSpaceMemberViewSet, WorkSpaceMemberViewSet,
WorkspaceMembersEndpoint,
WorkspaceInvitationsViewset, WorkspaceInvitationsViewset,
UserWorkspaceInvitationsEndpoint, UserWorkspaceInvitationsEndpoint,
WorkspaceMemberUserEndpoint, WorkspaceMemberUserEndpoint,
@ -59,6 +60,7 @@ from plane.api.views import (
ProjectViewSet, ProjectViewSet,
InviteProjectEndpoint, InviteProjectEndpoint,
ProjectMemberViewSet, ProjectMemberViewSet,
ProjectMemberEndpoint,
ProjectMemberInvitationsViewset, ProjectMemberInvitationsViewset,
ProjectMemberUserEndpoint, ProjectMemberUserEndpoint,
AddMemberToProjectEndpoint, AddMemberToProjectEndpoint,
@ -84,8 +86,10 @@ from plane.api.views import (
IssueAttachmentEndpoint, IssueAttachmentEndpoint,
IssueArchiveViewSet, IssueArchiveViewSet,
IssueSubscriberViewSet, IssueSubscriberViewSet,
IssueCommentPublicViewSet,
IssueReactionViewSet, IssueReactionViewSet,
CommentReactionViewSet, CommentReactionViewSet,
ExportIssuesEndpoint,
## End Issues ## End Issues
# States # States
StateViewSet, StateViewSet,
@ -162,6 +166,15 @@ from plane.api.views import (
NotificationViewSet, NotificationViewSet,
UnreadNotificationEndpoint, UnreadNotificationEndpoint,
## End Notification ## End Notification
# Public Boards
ProjectDeployBoardViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectDeployBoardPublicSettingsEndpoint,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
InboxIssuePublicViewSet,
IssueVotePublicViewSet,
## End Public Boards
) )
@ -334,6 +347,11 @@ urlpatterns = [
), ),
name="workspace", name="workspace",
), ),
path(
"workspaces/<str:slug>/workspace-members/",
WorkspaceMembersEndpoint.as_view(),
name="workspace-members",
),
path( path(
"workspaces/<str:slug>/teams/", "workspaces/<str:slug>/teams/",
TeamMemberViewSet.as_view( TeamMemberViewSet.as_view(
@ -467,6 +485,11 @@ urlpatterns = [
), ),
name="project", name="project",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/",
ProjectMemberEndpoint.as_view(),
name="project",
),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/add/", "workspaces/<str:slug>/projects/<uuid:project_id>/members/add/",
AddMemberToProjectEndpoint.as_view(), AddMemberToProjectEndpoint.as_view(),
@ -808,6 +831,11 @@ urlpatterns = [
IssueAttachmentEndpoint.as_view(), IssueAttachmentEndpoint.as_view(),
name="project-issue-attachments", name="project-issue-attachments",
), ),
path(
"workspaces/<str:slug>/export-issues/",
ExportIssuesEndpoint.as_view(),
name="export-issues",
),
## End Issues ## End Issues
## Issue Activity ## Issue Activity
path( path(
@ -1463,4 +1491,128 @@ urlpatterns = [
name="unread-notifications", name="unread-notifications",
), ),
## End Notification ## End Notification
# Public Boards
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/",
ProjectDeployBoardViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-deploy-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/<uuid:pk>/",
ProjectDeployBoardViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-deploy-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/settings/",
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
name="project-deploy-board-settings",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/",
ProjectDeployBoardIssuesPublicEndpoint.as_view(),
name="project-deploy-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
IssueCommentPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="issue-comments-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentPublicViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="issue-comments-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/",
IssueReactionPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="issue-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/<str:reaction_code>/",
IssueReactionPublicViewSet.as_view(
{
"delete": "destroy",
}
),
name="issue-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/",
CommentReactionPublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="comment-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/<str:reaction_code>/",
CommentReactionPublicViewSet.as_view(
{
"delete": "destroy",
}
),
name="comment-reactions-project-board",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
InboxIssuePublicViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="inbox-issue",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
InboxIssuePublicViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="inbox-issue",
),
path(
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/votes/",
IssueVotePublicViewSet.as_view(
{
"get": "list",
"post": "create",
"delete": "destroy",
}
),
name="issue-vote-project-board",
),
## End Public Boards
] ]

View File

@ -12,8 +12,12 @@ from .project import (
ProjectUserViewsEndpoint, ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint, ProjectMemberUserEndpoint,
ProjectFavoritesViewSet, ProjectFavoritesViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectDeployBoardViewSet,
ProjectDeployBoardPublicSettingsEndpoint,
ProjectMemberEndpoint,
) )
from .people import ( from .user import (
UserEndpoint, UserEndpoint,
UpdateUserOnBoardedEndpoint, UpdateUserOnBoardedEndpoint,
UpdateUserTourCompletedEndpoint, UpdateUserTourCompletedEndpoint,
@ -47,6 +51,7 @@ from .workspace import (
WorkspaceUserProfileEndpoint, WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint, WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint, WorkspaceLabelsEndpoint,
WorkspaceMembersEndpoint,
) )
from .state import StateViewSet from .state import StateViewSet
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
@ -73,8 +78,12 @@ from .issue import (
IssueAttachmentEndpoint, IssueAttachmentEndpoint,
IssueArchiveViewSet, IssueArchiveViewSet,
IssueSubscriberViewSet, IssueSubscriberViewSet,
IssueCommentPublicViewSet,
CommentReactionViewSet, CommentReactionViewSet,
IssueReactionViewSet, IssueReactionViewSet,
IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
) )
from .auth_extended import ( from .auth_extended import (
@ -142,7 +151,7 @@ from .estimate import (
from .release import ReleaseNotesEndpoint from .release import ReleaseNotesEndpoint
from .inbox import InboxViewSet, InboxIssueViewSet from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
from .analytic import ( from .analytic import (
AnalyticsEndpoint, AnalyticsEndpoint,
@ -152,4 +161,8 @@ from .analytic import (
DefaultAnalyticsEndpoint, DefaultAnalyticsEndpoint,
) )
from .notification import NotificationViewSet, UnreadNotificationEndpoint from .notification import NotificationViewSet, UnreadNotificationEndpoint
from .exporter import (
ExportIssuesEndpoint,
)

View File

@ -79,12 +79,12 @@ class AnalyticsEndpoint(BaseAPIView):
) )
assignee_details = {} 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 = ( assignee_details = (
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False) Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
.order_by("assignees__id") .order_by("assignees__id")
.distinct("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 = ( most_issue_created_user = (
queryset.exclude(created_by=None) 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", "created_by__id")
.annotate(count=Count("id")) .annotate(count=Count("id"))
.order_by("-count") .order_by("-count")
)[:5] )[:5]
most_issue_closed_user = ( most_issue_closed_user = (
queryset.filter(completed_at__isnull=False, assignees__isnull=False) 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", "assignees__id")
.annotate(count=Count("id")) .annotate(count=Count("id"))
.order_by("-count") .order_by("-count")
)[:5] )[:5]
pending_issue_user = ( pending_issue_user = (
queryset.filter(completed_at__isnull=True) 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", "assignees__id")
.annotate(count=Count("id")) .annotate(count=Count("id"))
.order_by("-count") .order_by("-count")
) )

View File

@ -22,7 +22,7 @@ from sentry_sdk import capture_exception
## Module imports ## Module imports
from . import BaseAPIView from . import BaseAPIView
from plane.api.serializers.people import ( from plane.api.serializers import (
ChangePasswordSerializer, ChangePasswordSerializer,
ResetPasswordSerializer, ResetPasswordSerializer,
) )

View File

@ -165,6 +165,9 @@ class CycleViewSet(BaseViewSet):
try: try:
queryset = self.get_queryset() queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all") cycle_view = request.GET.get("cycle_view", "all")
order_by = request.GET.get("order_by", "sort_order")
queryset = queryset.order_by(order_by)
# All Cycles # All Cycles
if cycle_view == "all": if cycle_view == "all":
@ -370,7 +373,8 @@ class CycleViewSet(BaseViewSet):
.annotate(last_name=F("assignees__last_name")) .annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id")) .annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar")) .annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar") .annotate(display_name=F("assignees__display_name"))
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.annotate(total_issues=Count("assignee_id")) .annotate(total_issues=Count("assignee_id"))
.annotate( .annotate(
completed_issues=Count( completed_issues=Count(

View File

@ -0,0 +1,100 @@
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module imports
from . import BaseAPIView
from plane.api.permissions import WorkSpaceAdminPermission
from plane.bgtasks.export_task import issue_export_task
from plane.db.models import Project, ExporterHistory, Workspace
from plane.api.serializers import ExporterHistorySerializer
class ExportIssuesEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
model = ExporterHistory
serializer_class = ExporterHistorySerializer
def post(self, request, slug):
try:
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
provider = request.data.get("provider", False)
multiple = request.data.get("multiple", False)
project_ids = request.data.get("project", [])
if provider in ["csv", "xlsx", "json"]:
if not project_ids:
project_ids = Project.objects.filter(
workspace__slug=slug
).values_list("id", flat=True)
project_ids = [str(project_id) for project_id in project_ids]
exporter = ExporterHistory.objects.create(
workspace=workspace,
project=project_ids,
initiated_by=request.user,
provider=provider,
)
issue_export_task.delay(
provider=exporter.provider,
workspace_id=workspace.id,
project_ids=project_ids,
token_id=exporter.token,
multiple=multiple,
slug=slug,
)
return Response(
{
"message": f"Once the export is ready you will be able to download it"
},
status=status.HTTP_200_OK,
)
else:
return Response(
{"error": f"Provider '{provider}' not found."},
status=status.HTTP_400_BAD_REQUEST,
)
except Workspace.DoesNotExist:
return Response(
{"error": "Workspace does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def get(self, request, slug):
try:
exporter_history = ExporterHistory.objects.filter(
workspace__slug=slug
).select_related("workspace","initiated_by")
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
return self.paginate(
request=request,
queryset=exporter_history,
on_results=lambda exporter_history: ExporterHistorySerializer(
exporter_history, many=True
).data,
)
else:
return Response(
{"error": "per_page and cursor are required"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -458,7 +458,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
actor=request.user, actor=request.user,
project_id=project_id, project_id=project_id,
workspace_id=project.workspace_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", verb="created",
created_by=request.user, created_by=request.user,
) )

View File

@ -15,7 +15,6 @@ from sentry_sdk import capture_exception
from .base import BaseViewSet from .base import BaseViewSet
from plane.api.permissions import ProjectBasePermission, ProjectLitePermission from plane.api.permissions import ProjectBasePermission, ProjectLitePermission
from plane.db.models import ( from plane.db.models import (
Project,
Inbox, Inbox,
InboxIssue, InboxIssue,
Issue, Issue,
@ -23,6 +22,7 @@ from plane.db.models import (
IssueLink, IssueLink,
IssueAttachment, IssueAttachment,
ProjectMember, ProjectMember,
ProjectDeployBoard,
) )
from plane.api.serializers import ( from plane.api.serializers import (
IssueSerializer, IssueSerializer,
@ -377,4 +377,269 @@ class InboxIssueViewSet(BaseViewSet):
return Response( return Response(
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
class InboxIssuePublicViewSet(BaseViewSet):
serializer_class = InboxIssueSerializer
model = InboxIssue
filterset_fields = [
"status",
]
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"))
if project_deploy_board is not None:
return self.filter_queryset(
super()
.get_queryset()
.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("issue", "workspace", "project")
)
else:
return InboxIssue.objects.none()
def list(self, request, slug, project_id, inbox_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.objects.filter(
issue_inbox__inbox_id=inbox_id,
workspace__slug=slug,
project_id=project_id,
)
.filter(**filters)
.annotate(bridge_id=F("issue_inbox__id"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.prefetch_related(
Prefetch(
"issue_inbox",
queryset=InboxIssue.objects.only(
"status", "duplicate_to", "snoozed_till", "source"
),
)
)
)
issues_data = IssueStateInboxSerializer(issues, many=True).data
return Response(
issues_data,
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response({"error": "Project Deploy Board does not exist"}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def create(self, request, slug, project_id, inbox_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
if not request.data.get("issue", {}).get("name", False):
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
)
# Check for valid priority
if not request.data.get("issue", {}).get("priority", None) in [
"low",
"medium",
"high",
"urgent",
None,
]:
return Response(
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
)
# Create or get state
state, _ = State.objects.get_or_create(
name="Triage",
group="backlog",
description="Default state for managing all Inbox Issues",
project_id=project_id,
color="#ff7700",
)
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
description=request.data.get("issue", {}).get("description", {}),
description_html=request.data.get("issue", {}).get(
"description_html", "<p></p>"
),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_id,
state=state,
)
# Create an Issue Activity
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
)
# create an inbox issue
InboxIssue.objects.create(
inbox_id=inbox_id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "in-app"),
)
serializer = IssueStateInboxSerializer(issue)
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,
)
def partial_update(self, request, slug, project_id, inbox_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
# Get the project member
if str(inbox_issue.created_by_id) != str(request.user.id):
return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST)
# Get issue data
issue_data = request.data.pop("issue", False)
issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
# viewers and guests since only viewers and guests
issue_data = {
"name": issue_data.get("name", issue.name),
"description_html": issue_data.get("description_html", issue.description_html),
"description": issue_data.get("description", issue.description)
}
issue_serializer = IssueCreateSerializer(
issue, data=issue_data, partial=True
)
if issue_serializer.is_valid():
current_instance = issue
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
)
issue_serializer.save()
return Response(issue_serializer.data, status=status.HTTP_200_OK)
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except InboxIssue.DoesNotExist:
return Response(
{"error": "Inbox Issue does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def retrieve(self, request, slug, project_id, inbox_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
issue = Issue.objects.get(
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueStateInboxSerializer(issue)
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,
)
def destroy(self, request, slug, project_id, inbox_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
if project_deploy_board.inbox is None:
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue = InboxIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
if str(inbox_issue.created_by_id) != str(request.user.id):
return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST)
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except InboxIssue.DoesNotExist:
return Response({"error": "Inbox Issue does not exists"}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -20,6 +20,17 @@ class SlackProjectSyncViewSet(BaseViewSet):
serializer_class = SlackProjectSyncSerializer serializer_class = SlackProjectSyncSerializer
model = SlackProjectSync model = SlackProjectSync
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.filter(project__project_projectmember__member=self.request.user)
)
def create(self, request, slug, project_id, workspace_integration_id): def create(self, request, slug, project_id, workspace_integration_id):
try: try:
serializer = SlackProjectSyncSerializer(data=request.data) serializer = SlackProjectSyncSerializer(data=request.data)
@ -45,7 +56,10 @@ class SlackProjectSyncViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError: except IntegrityError:
return Response({"error": "Slack is already enabled for the project"}, status=status.HTTP_400_BAD_REQUEST) return Response(
{"error": "Slack is already enabled for the project"},
status=status.HTTP_400_BAD_REQUEST,
)
except WorkspaceIntegration.DoesNotExist: except WorkspaceIntegration.DoesNotExist:
return Response( return Response(
{"error": "Workspace Integration does not exist"}, {"error": "Workspace Integration does not exist"},

View File

@ -48,6 +48,7 @@ from plane.api.serializers import (
ProjectMemberLiteSerializer, ProjectMemberLiteSerializer,
IssueReactionSerializer, IssueReactionSerializer,
CommentReactionSerializer, CommentReactionSerializer,
IssueVoteSerializer,
) )
from plane.api.permissions import ( from plane.api.permissions import (
WorkspaceEntityPermission, WorkspaceEntityPermission,
@ -70,6 +71,8 @@ from plane.db.models import (
ProjectMember, ProjectMember,
IssueReaction, IssueReaction,
CommentReaction, CommentReaction,
ProjectDeployBoard,
IssueVote,
) )
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results from plane.utils.grouper import group_results
@ -168,7 +171,6 @@ class IssueViewSet(BaseViewSet):
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
try: try:
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
print(filters)
# Custom ordering for priority and state # Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None] priority_order = ["urgent", "high", "medium", "low", None]
@ -361,6 +363,12 @@ class UserWorkSpaceIssues(BaseAPIView):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters) .filter(**filters)
) )
@ -743,21 +751,25 @@ class SubIssuesEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
) )
state_distribution = ( state_distribution = (
State.objects.filter(~Q(name="Triage"), workspace__slug=slug) State.objects.filter(
.annotate( workspace__slug=slug, state_issue__parent_id=issue_id
state_count=Count(
"state_issue",
filter=Q(state_issue__parent_id=issue_id),
)
) )
.order_by("group") .annotate(state_group=F("group"))
.values("group", "state_count") .values("state_group")
.annotate(state_count=Count("state_group"))
.order_by("state_group")
) )
result = {item["group"]: item["state_count"] for item in state_distribution} result = {item["state_group"]: item["state_count"] for item in state_distribution}
serializer = IssueLiteSerializer( serializer = IssueLiteSerializer(
sub_issues, sub_issues,
@ -1445,3 +1457,398 @@ class CommentReactionViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
class IssueCommentPublicViewSet(BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
filterset_fields = [
"issue__id",
"workspace__id",
]
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.comments:
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(issue_id=self.kwargs.get("issue_id"))
.select_related("project")
.select_related("workspace")
.select_related("issue")
.distinct()
)
else:
return IssueComment.objects.none()
def create(self, request, slug, project_id, issue_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
access = (
"INTERNAL"
if ProjectMember.objects.filter(
project_id=project_id, member=request.user
).exists()
else "EXTERNAL"
)
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
issue_id=issue_id,
actor=request.user,
access=access,
)
issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def partial_update(self, request, slug, project_id, issue_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
comment = IssueComment.objects.get(
workspace__slug=slug, pk=pk, actor=request.user
)
serializer = IssueCommentSerializer(
comment, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
type="comment.activity.updated",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
return Response(
{"error": "IssueComent Does not exists"},
status=status.HTTP_400_BAD_REQUEST,)
def destroy(self, request, slug, project_id, issue_id, pk):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.comments:
return Response(
{"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST,
)
comment = IssueComment.objects.get(
workspace__slug=slug, pk=pk, project_id=project_id, actor=request.user
)
issue_activity.delay(
type="comment.activity.deleted",
requested_data=json.dumps({"comment_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
),
)
comment.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
return Response(
{"error": "IssueComent Does not exists"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueReactionPublicViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.reactions:
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id"))
.order_by("-created_at")
.distinct()
)
else:
return IssueReaction.objects.none()
def create(self, request, slug, project_id, issue_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, issue_id=issue_id, actor=request.user
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project board does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST,
)
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,
issue_id=issue_id,
reaction=reaction_code,
actor=request.user,
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except IssueReaction.DoesNotExist:
return Response(
{"error": "Issue reaction does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class CommentReactionPublicViewSet(BaseViewSet):
serializer_class = CommentReactionSerializer
model = CommentReaction
def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
if project_deploy_board.reactions:
return (
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(comment_id=self.kwargs.get("comment_id"))
.order_by("-created_at")
.distinct()
)
else:
return CommentReaction.objects.none()
def create(self, request, slug, project_id, comment_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, comment_id=comment_id, actor=request.user
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project board does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
if not project_deploy_board.reactions:
return Response(
{"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST,
)
comment_reaction = CommentReaction.objects.get(
project_id=project_id,
workspace__slug=slug,
comment_id=comment_id,
reaction=reaction_code,
actor=request.user,
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except CommentReaction.DoesNotExist:
return Response(
{"error": "Comment reaction does not exist"},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueVotePublicViewSet(BaseViewSet):
model = IssueVote
serializer_class = IssueVoteSerializer
def get_queryset(self):
return (
super()
.get_queryset()
.filter(issue_id=self.kwargs.get("issue_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
)
def create(self, request, slug, project_id, issue_id):
try:
issue_vote, _ = IssueVote.objects.get_or_create(
actor_id=request.user.id,
project_id=project_id,
issue_id=issue_id,
vote=request.data.get("vote", 1),
)
serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def destroy(self, request, slug, project_id, issue_id):
try:
issue_vote = IssueVote.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
actor_id=request.user.id,
)
issue_vote.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e:
capture_exception(e)
return Response(
{"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,
)

View File

@ -53,6 +53,8 @@ class ModuleViewSet(BaseViewSet):
) )
def get_queryset(self): def get_queryset(self):
order_by = self.request.GET.get("order_by", "sort_order")
subquery = ModuleFavorite.objects.filter( subquery = ModuleFavorite.objects.filter(
user=self.request.user, user=self.request.user,
module_id=OuterRef("pk"), module_id=OuterRef("pk"),
@ -106,7 +108,7 @@ class ModuleViewSet(BaseViewSet):
filter=Q(issue_module__issue__state__group="backlog"), filter=Q(issue_module__issue__state__group="backlog"),
) )
) )
.order_by("-is_favorite", "name") .order_by(order_by, "name")
) )
def perform_destroy(self, instance): def perform_destroy(self, instance):
@ -173,8 +175,9 @@ class ModuleViewSet(BaseViewSet):
.annotate(first_name=F("assignees__first_name")) .annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name")) .annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id")) .annotate(assignee_id=F("assignees__id"))
.annotate(display_name=F("assignees__display_name"))
.annotate(avatar=F("assignees__avatar")) .annotate(avatar=F("assignees__avatar"))
.values("first_name", "last_name", "assignee_id", "avatar") .values("first_name", "last_name", "assignee_id", "avatar", "display_name")
.annotate(total_issues=Count("assignee_id")) .annotate(total_issues=Count("assignee_id"))
.annotate( .annotate(
completed_issues=Count( completed_issues=Count(

View File

@ -301,7 +301,7 @@ class CreateIssueFromPageBlockEndpoint(BaseAPIView):
issue=issue, issue=issue,
actor=request.user, actor=request.user,
project_id=project_id, 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", verb="created",
) )

View File

@ -5,7 +5,21 @@ from datetime import datetime
# Django imports # Django imports
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Q, Exists, OuterRef, Func, F, Min, Subquery from django.db.models import (
Q,
Exists,
OuterRef,
Func,
F,
Max,
CharField,
Func,
Subquery,
Prefetch,
When,
Case,
Value,
)
from django.core.validators import validate_email from django.core.validators import validate_email
from django.conf import settings from django.conf import settings
@ -13,6 +27,7 @@ from django.conf import settings
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework import serializers from rest_framework import serializers
from rest_framework.permissions import AllowAny
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
# Module imports # Module imports
@ -23,9 +38,16 @@ from plane.api.serializers import (
ProjectDetailSerializer, ProjectDetailSerializer,
ProjectMemberInviteSerializer, ProjectMemberInviteSerializer,
ProjectFavoriteSerializer, ProjectFavoriteSerializer,
IssueLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
) )
from plane.api.permissions import ProjectBasePermission from plane.api.permissions import (
ProjectBasePermission,
ProjectEntityPermission,
ProjectMemberPermission,
)
from plane.db.models import ( from plane.db.models import (
Project, Project,
@ -48,9 +70,17 @@ from plane.db.models import (
IssueAssignee, IssueAssignee,
ModuleMember, ModuleMember,
Inbox, Inbox,
ProjectDeployBoard,
Issue,
IssueReaction,
IssueLink,
IssueAttachment,
Label,
) )
from plane.bgtasks.project_invitation_task import project_invitation from plane.bgtasks.project_invitation_task import project_invitation
from plane.utils.grouper import group_results
from plane.utils.issue_filters import issue_filters
class ProjectViewSet(BaseViewSet): class ProjectViewSet(BaseViewSet):
@ -92,7 +122,9 @@ class ProjectViewSet(BaseViewSet):
) )
) )
.annotate( .annotate(
total_members=ProjectMember.objects.filter(project_id=OuterRef("id")) total_members=ProjectMember.objects.filter(
project_id=OuterRef("id"), member__is_bot=False
)
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
@ -109,6 +141,20 @@ class ProjectViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(
member_role=ProjectMember.objects.filter(
project_id=OuterRef("pk"),
member_id=self.request.user.id,
).values("role")
)
.annotate(
is_deployed=Exists(
ProjectDeployBoard.objects.filter(
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
)
)
)
.distinct() .distinct()
) )
@ -180,7 +226,9 @@ class ProjectViewSet(BaseViewSet):
project_id=serializer.data["id"], member=request.user, role=20 project_id=serializer.data["id"], member=request.user, role=20
) )
if serializer.data["project_lead"] is not None: if serializer.data["project_lead"] is not None and str(
serializer.data["project_lead"]
) != str(request.user.id):
ProjectMember.objects.create( ProjectMember.objects.create(
project_id=serializer.data["id"], project_id=serializer.data["id"],
member_id=serializer.data["project_lead"], member_id=serializer.data["project_lead"],
@ -347,7 +395,9 @@ class InviteProjectEndpoint(BaseAPIView):
validate_email(email) validate_email(email)
# Check if user is already a member of workspace # Check if user is already a member of workspace
if ProjectMember.objects.filter( if ProjectMember.objects.filter(
project_id=project_id, member__email=email project_id=project_id,
member__email=email,
member__is_bot=False,
).exists(): ).exists():
return Response( return Response(
{"error": "User is already member of workspace"}, {"error": "User is already member of workspace"},
@ -451,14 +501,14 @@ class UserProjectInvitationsViewset(BaseViewSet):
class ProjectMemberViewSet(BaseViewSet): class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberSerializer serializer_class = ProjectMemberAdminSerializer
model = ProjectMember model = ProjectMember
permission_classes = [ permission_classes = [
ProjectBasePermission, ProjectBasePermission,
] ]
search_fields = [ search_fields = [
"member__email", "member__display_name",
"member__first_name", "member__first_name",
] ]
@ -984,3 +1034,255 @@ class ProjectFavoritesViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
class ProjectDeployBoardViewSet(BaseViewSet):
permission_classes = [
ProjectMemberPermission,
]
serializer_class = ProjectDeployBoardSerializer
model = ProjectDeployBoard
def get_queryset(self):
return (
super()
.get_queryset()
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
)
.select_related("project")
)
def create(self, request, slug, project_id):
try:
comments = request.data.get("comments", False)
reactions = request.data.get("reactions", False)
inbox = request.data.get("inbox", None)
votes = request.data.get("votes", False)
views = request.data.get(
"views",
{
"list": True,
"kanban": True,
"calendar": True,
"gantt": True,
"spreadsheet": True,
},
)
project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create(
anchor=f"{slug}/{project_id}",
project_id=project_id,
)
project_deploy_board.comments = comments
project_deploy_board.reactions = reactions
project_deploy_board.inbox = inbox
project_deploy_board.votes = votes
project_deploy_board.views = views
project_deploy_board.save()
serializer = ProjectDeployBoardSerializer(project_deploy_board)
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,
)
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,
member__is_bot=False,
).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,
)
class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
serializer = ProjectDeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Project Deploy Board does not exists"},
status=status.HTTP_404_NOT_FOUND,
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class ProjectDeployBoardIssuesPublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
try:
project_deploy_board = ProjectDeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
states = State.objects.filter(
workspace__slug=slug, project_id=project_id
).values("name", "group", "color", "id")
labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id
).values("id", "name", "color", "parent")
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
issues = group_results(issues, group_by)
return Response(
{
"issues": issues,
"states": states,
"labels": labels,
},
status=status.HTTP_200_OK,
)
except ProjectDeployBoard.DoesNotExist:
return Response(
{"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -19,6 +19,7 @@ from plane.db.models import (
IssueView, IssueView,
Issue, Issue,
IssueViewFavorite, IssueViewFavorite,
IssueReaction,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
@ -77,6 +78,12 @@ class ViewIssuesEndpoint(BaseAPIView):
.select_related("parent") .select_related("parent")
.prefetch_related("assignees") .prefetch_related("assignees")
.prefetch_related("labels") .prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
) )
serializer = IssueLiteSerializer(issues, many=True) serializer = IssueLiteSerializer(issues, many=True)

View File

@ -47,6 +47,7 @@ from plane.api.serializers import (
WorkspaceThemeSerializer, WorkspaceThemeSerializer,
IssueActivitySerializer, IssueActivitySerializer,
IssueLiteSerializer, IssueLiteSerializer,
WorkspaceMemberAdminSerializer,
) )
from plane.api.views.base import BaseAPIView from plane.api.views.base import BaseAPIView
from . import BaseViewSet from . import BaseViewSet
@ -106,7 +107,9 @@ class WorkSpaceViewSet(BaseViewSet):
def get_queryset(self): def get_queryset(self):
member_count = ( member_count = (
WorkspaceMember.objects.filter(workspace=OuterRef("id")) WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False
)
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
@ -191,7 +194,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
def get(self, request): def get(self, request):
try: try:
member_count = ( member_count = (
WorkspaceMember.objects.filter(workspace=OuterRef("id")) WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False
)
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
@ -537,7 +542,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
class WorkSpaceMemberViewSet(BaseViewSet): class WorkSpaceMemberViewSet(BaseViewSet):
serializer_class = WorkSpaceMemberSerializer serializer_class = WorkspaceMemberAdminSerializer
model = WorkspaceMember model = WorkspaceMember
permission_classes = [ permission_classes = [
@ -545,7 +550,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
] ]
search_fields = [ search_fields = [
"member__email", "member__display_name",
"member__first_name", "member__first_name",
] ]
@ -624,7 +629,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
if ( if (
workspace_member.role == 20 workspace_member.role == 20
and WorkspaceMember.objects.filter( and WorkspaceMember.objects.filter(
workspace__slug=slug, role=20 workspace__slug=slug,
role=20,
member__is_bot=False,
).count() ).count()
== 1 == 1
): ):
@ -690,7 +697,7 @@ class TeamMemberViewSet(BaseViewSet):
] ]
search_fields = [ search_fields = [
"member__email", "member__display_name",
"member__first_name", "member__first_name",
] ]
@ -1048,7 +1055,6 @@ class WorkspaceThemeViewSet(BaseViewSet):
class WorkspaceUserProfileStatsEndpoint(BaseAPIView): class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
def get(self, request, slug, user_id): def get(self, request, slug, user_id):
try: try:
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
@ -1146,14 +1152,18 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
upcoming_cycles = CycleIssue.objects.filter( upcoming_cycles = CycleIssue.objects.filter(
workspace__slug=slug, workspace__slug=slug,
cycle__start_date__gt=timezone.now().date(), 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") ).values("cycle__name", "cycle__id", "cycle__project_id")
present_cycle = CycleIssue.objects.filter( present_cycle = CycleIssue.objects.filter(
workspace__slug=slug, workspace__slug=slug,
cycle__start_date__lt=timezone.now().date(), cycle__start_date__lt=timezone.now().date(),
cycle__end_date__gt=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") ).values("cycle__name", "cycle__id", "cycle__project_id")
return Response( return Response(
@ -1166,7 +1176,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
"pending_issues": pending_issues_count, "pending_issues": pending_issues_count,
"subscribed_issues": subscribed_issues_count, "subscribed_issues": subscribed_issues_count,
"present_cycles": present_cycle, "present_cycles": present_cycle,
"upcoming_cycles": upcoming_cycles, "upcoming_cycles": upcoming_cycles,
} }
) )
except Exception as e: except Exception as e:
@ -1184,7 +1194,6 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
def get(self, request, slug, user_id): def get(self, request, slug, user_id):
try: try:
projects = request.query_params.getlist("project", []) projects = request.query_params.getlist("project", [])
queryset = IssueActivity.objects.filter( queryset = IssueActivity.objects.filter(
@ -1212,12 +1221,13 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
class WorkspaceUserProfileEndpoint(BaseAPIView): class WorkspaceUserProfileEndpoint(BaseAPIView):
def get(self, request, slug, user_id): def get(self, request, slug, user_id):
try: try:
user_data = User.objects.get(pk=user_id) 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 = [] projects = []
if requesting_workspace_member.role >= 10: if requesting_workspace_member.role >= 10:
projects = ( projects = (
@ -1227,7 +1237,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
) )
.annotate( .annotate(
created_issues=Count( 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( .annotate(
@ -1282,6 +1293,7 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
"cover_image": user_data.cover_image, "cover_image": user_data.cover_image,
"date_joined": user_data.date_joined, "date_joined": user_data.date_joined,
"user_timezone": user_data.user_timezone, "user_timezone": user_data.user_timezone,
"display_name": user_data.display_name,
}, },
}, },
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
@ -1439,3 +1451,24 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"}, {"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST, 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,
member__is_bot=False,
).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,
)

View File

@ -21,7 +21,7 @@ row_mapping = {
"state__name": "State", "state__name": "State",
"state__group": "State Group", "state__group": "State Group",
"labels__name": "Label", "labels__name": "Label",
"assignees__email": "Assignee Name", "assignees__display_name": "Assignee Name",
"start_date": "Start Date", "start_date": "Start Date",
"target_date": "Due Date", "target_date": "Due Date",
"completed_at": "Completed At", "completed_at": "Completed At",
@ -51,12 +51,12 @@ def analytic_export_task(email, data, slug):
segmented = segment segmented = segment
assignee_details = {} 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 = ( assignee_details = (
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False) Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
.order_by("assignees__id") .order_by("assignees__id")
.distinct("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")
) )
if segment: if segment:
@ -93,19 +93,19 @@ def analytic_export_task(email, data, slug):
else: else:
generated_row.append("0") generated_row.append("0")
# x-axis replacement for names # x-axis replacement for names
if x_axis in ["assignees__email"]: if x_axis in ["assignees__id"]:
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)] assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
if len(assignee): if len(assignee):
generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name")) generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows.append(tuple(generated_row)) rows.append(tuple(generated_row))
# If segment is ["assignees__email"] then replace segment_zero rows with first and last names # If segment is ["assignees__display_name"] then replace segment_zero rows with first and last names
if segmented in ["assignees__email"]: if segmented in ["assignees__id"]:
for index, segm in enumerate(row_zero[2:]): for index, segm in enumerate(row_zero[2:]):
# find the name of the user # 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__id")) == str(segm)]
if len(assignee): if len(assignee):
row_zero[index] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name")) row_zero[index + 2] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
rows = [tuple(row_zero)] + rows rows = [tuple(row_zero)] + rows
csv_buffer = io.StringIO() csv_buffer = io.StringIO()
@ -141,8 +141,8 @@ def analytic_export_task(email, data, slug):
else distribution.get(item)[0].get("estimate "), else distribution.get(item)[0].get("estimate "),
] ]
# x-axis replacement to names # x-axis replacement to names
if x_axis in ["assignees__email"]: if x_axis in ["assignees__id"]:
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)] assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
if len(assignee): if len(assignee):
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name")) row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))

View File

@ -0,0 +1,346 @@
# Python imports
import csv
import io
import json
import boto3
import zipfile
# Django imports
from django.conf import settings
from django.utils import timezone
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from botocore.client import Config
from openpyxl import Workbook
# Module imports
from plane.db.models import Issue, ExporterHistory
def dateTimeConverter(time):
if time:
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
def dateConverter(time):
if time:
return time.strftime("%a, %d %b %Y")
def create_csv_file(data):
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
for row in data:
csv_writer.writerow(row)
csv_buffer.seek(0)
return csv_buffer.getvalue()
def create_json_file(data):
return json.dumps(data)
def create_xlsx_file(data):
workbook = Workbook()
sheet = workbook.active
for row in data:
sheet.append(row)
xlsx_buffer = io.BytesIO()
workbook.save(xlsx_buffer)
xlsx_buffer.seek(0)
return xlsx_buffer.getvalue()
def create_zip_file(files):
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
for filename, file_content in files:
zipf.writestr(filename, file_content)
zip_buffer.seek(0)
return zip_buffer
def upload_to_s3(zip_file, workspace_id, token_id, slug):
s3 = boto3.client(
"s3",
region_name="ap-south-1",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
s3.upload_fileobj(
zip_file,
settings.AWS_S3_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
expires_in = 7 * 24 * 60 * 60
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)
exporter_instance = ExporterHistory.objects.get(token=token_id)
if presigned_url:
exporter_instance.url = presigned_url
exporter_instance.status = "completed"
exporter_instance.key = file_name
else:
exporter_instance.status = "failed"
exporter_instance.save(update_fields=["status", "url","key"])
def generate_table_row(issue):
return [
f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
issue["project__name"],
issue["name"],
issue["description_stripped"],
issue["state__name"],
issue["priority"],
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
else "",
f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
if issue["assignees__first_name"] and issue["assignees__last_name"]
else "",
issue["labels__name"],
issue["issue_cycle__cycle__name"],
dateConverter(issue["issue_cycle__cycle__start_date"]),
dateConverter(issue["issue_cycle__cycle__end_date"]),
issue["issue_module__module__name"],
dateConverter(issue["issue_module__module__start_date"]),
dateConverter(issue["issue_module__module__target_date"]),
dateTimeConverter(issue["created_at"]),
dateTimeConverter(issue["updated_at"]),
dateTimeConverter(issue["completed_at"]),
dateTimeConverter(issue["archived_at"]),
]
def generate_json_row(issue):
return {
"ID": f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
"Project": issue["project__name"],
"Name": issue["name"],
"Description": issue["description_stripped"],
"State": issue["state__name"],
"Priority": issue["priority"],
"Created By": f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
else "",
"Assignee": f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
if issue["assignees__first_name"] and issue["assignees__last_name"]
else "",
"Labels": issue["labels__name"],
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
"Archived At": dateTimeConverter(issue["archived_at"]),
}
def update_json_row(rows, row):
matched_index = next(
(
index
for index, existing_row in enumerate(rows)
if existing_row["ID"] == row["ID"]
),
None,
)
if matched_index is not None:
existing_assignees, existing_labels = (
rows[matched_index]["Assignee"],
rows[matched_index]["Labels"],
)
assignee, label = row["Assignee"], row["Labels"]
if assignee is not None and assignee not in existing_assignees:
rows[matched_index]["Assignee"] += f", {assignee}"
if label is not None and label not in existing_labels:
rows[matched_index]["Labels"] += f", {label}"
else:
rows.append(row)
def update_table_row(rows, row):
matched_index = next(
(index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]),
None,
)
if matched_index is not None:
existing_assignees, existing_labels = rows[matched_index][7:9]
assignee, label = row[7:9]
if assignee is not None and assignee not in existing_assignees:
rows[matched_index][7] += f", {assignee}"
if label is not None and label not in existing_labels:
rows[matched_index][8] += f", {label}"
else:
rows.append(row)
def generate_csv(header, project_id, issues, files):
"""
Generate CSV export for all the passed issues.
"""
rows = [
header,
]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
csv_file = create_csv_file(rows)
files.append((f"{project_id}.csv", csv_file))
def generate_json(header, project_id, issues, files):
rows = []
for issue in issues:
row = generate_json_row(issue)
update_json_row(rows, row)
json_file = create_json_file(rows)
files.append((f"{project_id}.json", json_file))
def generate_xlsx(header, project_id, issues, files):
rows = [header]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
xlsx_file = create_xlsx_file(rows)
files.append((f"{project_id}.xlsx", xlsx_file))
@shared_task
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
try:
exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "processing"
exporter_instance.save(update_fields=["status"])
workspace_issues = (
(
Issue.objects.filter(
workspace__id=workspace_id, project_id__in=project_ids
)
.select_related("project", "workspace", "state", "parent", "created_by")
.prefetch_related(
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
)
.values(
"id",
"project__identifier",
"project__name",
"project__id",
"sequence_id",
"name",
"description_stripped",
"priority",
"state__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",
)
)
.order_by("project__identifier","sequence_id")
.distinct()
)
# CSV header
header = [
"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",
]
EXPORTER_MAPPER = {
"csv": generate_csv,
"json": generate_json,
"xlsx": generate_xlsx,
}
files = []
if multiple:
for project_id in project_ids:
issues = workspace_issues.filter(project__id=project_id)
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(
header,
project_id,
issues,
files,
)
else:
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(
header,
workspace_id,
workspace_issues,
files,
)
zip_buffer = create_zip_file(files)
upload_to_s3(zip_buffer, workspace_id, token_id, slug)
except Exception as e:
exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "failed"
exporter_instance.reason = str(e)
exporter_instance.save(update_fields=["status", "reason"])
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@ -0,0 +1,38 @@
# Python imports
import boto3
from datetime import timedelta
# Django imports
from django.conf import settings
from django.utils import timezone
from django.db.models import Q
# Third party imports
from celery import shared_task
from botocore.client import Config
# Module imports
from plane.db.models import ExporterHistory
@shared_task
def delete_old_s3_link():
# Get a list of keys and IDs to process
expired_exporter_history = ExporterHistory.objects.filter(
Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8))
).values_list("key", "id")
s3 = boto3.client(
"s3",
region_name="ap-south-1",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
for file_name, exporter_id in expired_exporter_history:
# Delete object from S3
if file_name:
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
ExporterHistory.objects.filter(id=exporter_id).update(url=None)

View File

@ -48,7 +48,7 @@ def track_name(
field="name", field="name",
project=project, project=project,
workspace=project.workspace, 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", field="parent",
project=project, project=project,
workspace=project.workspace, 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, old_identifier=old_parent.id,
new_identifier=None, new_identifier=None,
) )
@ -95,7 +95,7 @@ def track_parent(
field="parent", field="parent",
project=project, project=project,
workspace=project.workspace, 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, old_identifier=old_parent.id if old_parent is not None else None,
new_identifier=new_parent.id, new_identifier=new_parent.id,
) )
@ -123,7 +123,7 @@ def track_priority(
field="priority", field="priority",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} updated the priority to None", comment=f"updated the priority to None",
) )
) )
else: else:
@ -137,7 +137,7 @@ def track_priority(
field="priority", field="priority",
project=project, project=project,
workspace=project.workspace, 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", field="state",
project=project, project=project,
workspace=project.workspace, 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, old_identifier=old_state.id,
new_identifier=new_state.id, new_identifier=new_state.id,
) )
@ -194,7 +194,7 @@ def track_description(
field="description", field="description",
project=project, project=project,
workspace=project.workspace, 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", field="target_date",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} updated the target date to None", comment=f"updated the target date to None",
) )
) )
else: else:
@ -234,7 +234,7 @@ def track_target_date(
field="target_date", field="target_date",
project=project, project=project,
workspace=project.workspace, 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", field="start_date",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} updated the start date to None", comment=f"updated the start date to None",
) )
) )
else: else:
@ -274,7 +274,7 @@ def track_start_date(
field="start_date", field="start_date",
project=project, project=project,
workspace=project.workspace, 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", field="labels",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} added label {label.name}", comment=f"added label {label.name}",
new_identifier=label.id, new_identifier=label.id,
old_identifier=None, old_identifier=None,
) )
@ -324,7 +324,7 @@ def track_labels(
field="labels", field="labels",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} removed label {label.name}", comment=f"removed label {label.name}",
old_identifier=label.id, old_identifier=label.id,
new_identifier=None, new_identifier=None,
) )
@ -353,12 +353,12 @@ def track_assignees(
actor=actor, actor=actor,
verb="updated", verb="updated",
old_value="", old_value="",
new_value=assignee.email, new_value=assignee.display_name,
field="assignees", field="assignees",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} added assignee {assignee.email}", comment=f"added assignee {assignee.display_name}",
new_identifier=actor.id, new_identifier=assignee.id,
) )
) )
@ -374,13 +374,13 @@ def track_assignees(
issue_id=issue_id, issue_id=issue_id,
actor=actor, actor=actor,
verb="updated", verb="updated",
old_value=assignee.email, old_value=assignee.display_name,
new_value="", new_value="",
field="assignees", field="assignees",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} removed assignee {assignee.email}", comment=f"removed assignee {assignee.display_name}",
old_identifier=actor.id, old_identifier=assignee.id,
) )
) )
@ -419,7 +419,7 @@ def track_blocks(
field="blocks", field="blocks",
project=project, project=project,
workspace=project.workspace, 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, new_identifier=issue.id,
) )
) )
@ -441,7 +441,7 @@ def track_blocks(
field="blocks", field="blocks",
project=project, project=project,
workspace=project.workspace, 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, old_identifier=issue.id,
) )
) )
@ -481,7 +481,7 @@ def track_blockings(
field="blocking", field="blocking",
project=project, project=project,
workspace=project.workspace, 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, new_identifier=issue.id,
) )
) )
@ -503,7 +503,7 @@ def track_blockings(
field="blocking", field="blocking",
project=project, project=project,
workspace=project.workspace, 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, old_identifier=issue.id,
) )
) )
@ -517,7 +517,7 @@ def create_issue_activity(
issue_id=issue_id, issue_id=issue_id,
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} created the issue", comment=f"created the issue",
verb="created", verb="created",
actor=actor, actor=actor,
) )
@ -539,7 +539,7 @@ def track_estimate_points(
field="estimate_point", field="estimate_point",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} updated the estimate point to None", comment=f"updated the estimate point to None",
) )
) )
else: else:
@ -553,7 +553,7 @@ def track_estimate_points(
field="estimate_point", field="estimate_point",
project=project, project=project,
workspace=project.workspace, 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, issue_id=issue_id,
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} has restored the issue", comment=f"has restored the issue",
verb="updated", verb="updated",
actor=actor, actor=actor,
field="archived_at", field="archived_at",
@ -661,7 +661,7 @@ def delete_issue_activity(
IssueActivity( IssueActivity(
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} deleted the issue", comment=f"deleted the issue",
verb="deleted", verb="deleted",
actor=actor, actor=actor,
field="issue", field="issue",
@ -682,7 +682,7 @@ def create_comment_activity(
issue_id=issue_id, issue_id=issue_id,
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} created a comment", comment=f"created a comment",
verb="created", verb="created",
actor=actor, actor=actor,
field="comment", field="comment",
@ -707,7 +707,7 @@ def update_comment_activity(
issue_id=issue_id, issue_id=issue_id,
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} updated a comment", comment=f"updated a comment",
verb="updated", verb="updated",
actor=actor, actor=actor,
field="comment", field="comment",
@ -728,7 +728,7 @@ def delete_comment_activity(
issue_id=issue_id, issue_id=issue_id,
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} deleted the comment", comment=f"deleted the comment",
verb="deleted", verb="deleted",
actor=actor, actor=actor,
field="comment", field="comment",
@ -766,7 +766,7 @@ def create_cycle_issue_activity(
field="cycles", field="cycles",
project=project, project=project,
workspace=project.workspace, 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, old_identifier=old_cycle.id,
new_identifier=new_cycle.id, new_identifier=new_cycle.id,
) )
@ -787,7 +787,7 @@ def create_cycle_issue_activity(
field="cycles", field="cycles",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} added cycle {cycle.name}", comment=f"added cycle {cycle.name}",
new_identifier=cycle.id, new_identifier=cycle.id,
) )
) )
@ -816,7 +816,7 @@ def delete_cycle_issue_activity(
field="cycles", field="cycles",
project=project, project=project,
workspace=project.workspace, 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, old_identifier=cycle.id if cycle is not None else None,
) )
) )
@ -852,7 +852,7 @@ def create_module_issue_activity(
field="modules", field="modules",
project=project, project=project,
workspace=project.workspace, 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, old_identifier=old_module.id,
new_identifier=new_module.id, new_identifier=new_module.id,
) )
@ -872,7 +872,7 @@ def create_module_issue_activity(
field="modules", field="modules",
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} added module {module.name}", comment=f"added module {module.name}",
new_identifier=module.id, new_identifier=module.id,
) )
) )
@ -901,7 +901,7 @@ def delete_module_issue_activity(
field="modules", field="modules",
project=project, project=project,
workspace=project.workspace, 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, old_identifier=module.id if module is not None else None,
) )
) )
@ -920,7 +920,7 @@ def create_link_activity(
issue_id=issue_id, issue_id=issue_id,
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} created a link", comment=f"created a link",
verb="created", verb="created",
actor=actor, actor=actor,
field="link", field="link",
@ -944,7 +944,7 @@ def update_link_activity(
issue_id=issue_id, issue_id=issue_id,
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} updated a link", comment=f"updated a link",
verb="updated", verb="updated",
actor=actor, actor=actor,
field="link", field="link",
@ -969,7 +969,7 @@ def delete_link_activity(
issue_id=issue_id, issue_id=issue_id,
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} deleted the link", comment=f"deleted the link",
verb="deleted", verb="deleted",
actor=actor, actor=actor,
field="link", field="link",
@ -992,7 +992,7 @@ def create_attachment_activity(
issue_id=issue_id, issue_id=issue_id,
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} created an attachment", comment=f"created an attachment",
verb="created", verb="created",
actor=actor, actor=actor,
field="attachment", field="attachment",
@ -1010,7 +1010,7 @@ def delete_attachment_activity(
issue_id=issue_id, issue_id=issue_id,
project=project, project=project,
workspace=project.workspace, workspace=project.workspace,
comment=f"{actor.email} deleted the attachment", comment=f"deleted the attachment",
verb="deleted", verb="deleted",
actor=actor, actor=actor,
field="attachment", field="attachment",

View File

@ -20,6 +20,10 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues", "task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues",
"schedule": crontab(hour=0, minute=0), "schedule": crontab(hour=0, minute=0),
}, },
"check-every-day-to-delete_exporter_history": {
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
"schedule": crontab(hour=0, minute=0),
},
} }
# Load task modules from all registered Django app configs. # Load task modules from all registered Django app configs.

View File

@ -0,0 +1,238 @@
# Generated by Django 4.2.3 on 2023-08-14 07:12
from django.conf import settings
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.exporter
import plane.db.models.project
import uuid
import random
import string
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)
def random_cycle_order(apps, schema_editor):
CycleModel = apps.get_model("db", "Cycle")
updated_cycles = []
for obj in CycleModel.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_cycles.append(obj)
CycleModel.objects.bulk_update(updated_cycles, ["sort_order"], batch_size=100)
def random_module_order(apps, schema_editor):
ModuleModel = apps.get_model("db", "Module")
updated_modules = []
for obj in ModuleModel.objects.all():
obj.sort_order = random.randint(1, 65536)
updated_modules.append(obj)
ModuleModel.objects.bulk_update(updated_modules, ["sort_order"], batch_size=100)
def update_user_issue_properties(apps, schema_editor):
IssuePropertyModel = apps.get_model("db", "IssueProperty")
updated_issue_properties = []
for obj in IssuePropertyModel.objects.all():
obj.properties["start_date"] = True
updated_issue_properties.append(obj)
IssuePropertyModel.objects.bulk_update(
updated_issue_properties, ["properties"], batch_size=100
)
def workspace_member_properties(apps, schema_editor):
WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember")
updated_workspace_members = []
for obj in WorkspaceMemberModel.objects.all():
obj.view_props["properties"]["start_date"] = True
obj.default_props["properties"]["start_date"] = True
updated_workspace_members.append(obj)
WorkspaceMemberModel.objects.bulk_update(
updated_workspace_members, ["view_props", "default_props"], batch_size=100
)
class Migration(migrations.Migration):
dependencies = [
('db', '0040_projectmember_preferences_user_cover_image_and_more'),
]
operations = [
migrations.AddField(
model_name='cycle',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name='issuecomment',
name='access',
field=models.CharField(choices=[('INTERNAL', 'INTERNAL'), ('EXTERNAL', 'EXTERNAL')], default='INTERNAL', max_length=100),
),
migrations.AddField(
model_name='module',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AddField(
model_name='user',
name='display_name',
field=models.CharField(default='', max_length=255),
),
migrations.CreateModel(
name='ExporterHistory',
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)),
('project', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(default=uuid.uuid4), blank=True, null=True, size=None)),
('provider', models.CharField(choices=[('json', 'json'), ('csv', 'csv'), ('xlsx', 'xlsx')], max_length=50)),
('status', models.CharField(choices=[('queued', 'Queued'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='queued', max_length=50)),
('reason', models.TextField(blank=True)),
('key', models.TextField(blank=True)),
('url', models.URLField(blank=True, max_length=800, null=True)),
('token', models.CharField(default=plane.db.models.exporter.generate_token, max_length=255, unique=True)),
('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')),
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_exporters', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_exporters', to='db.workspace')),
],
options={
'verbose_name': 'Exporter',
'verbose_name_plural': 'Exporters',
'db_table': 'exporters',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='ProjectDeployBoard',
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)),
('anchor', models.CharField(db_index=True, default=plane.db.models.project.get_anchor, max_length=255, unique=True)),
('comments', models.BooleanField(default=False)),
('reactions', models.BooleanField(default=False)),
('votes', models.BooleanField(default=False)),
('views', models.JSONField(default=plane.db.models.project.get_default_views)),
('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')),
('inbox', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bord_inbox', to='db.inbox')),
('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': 'Project Deploy Board',
'verbose_name_plural': 'Project Deploy Boards',
'db_table': 'project_deploy_boards',
'ordering': ('-created_at',),
'unique_together': {('project', 'anchor')},
},
),
migrations.CreateModel(
name='IssueVote',
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)),
('vote', models.IntegerField(choices=[(-1, 'DOWNVOTE'), (1, 'UPVOTE')])),
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', 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='votes', 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 Vote',
'verbose_name_plural': 'Issue Votes',
'db_table': 'issue_votes',
'ordering': ('-created_at',),
'unique_together': {('issue', 'actor')},
},
),
migrations.RunPython(generate_display_name),
migrations.RunPython(rectify_field_issue_activity),
migrations.RunPython(update_assignee_issue_activity),
migrations.RunPython(update_name_activity),
migrations.RunPython(random_cycle_order),
migrations.RunPython(random_module_order),
migrations.RunPython(update_user_issue_properties),
migrations.RunPython(workspace_member_properties),
]

View File

@ -18,6 +18,7 @@ from .project import (
ProjectMemberInvite, ProjectMemberInvite,
ProjectIdentifier, ProjectIdentifier,
ProjectFavorite, ProjectFavorite,
ProjectDeployBoard,
) )
from .issue import ( from .issue import (
@ -36,6 +37,7 @@ from .issue import (
IssueSubscriber, IssueSubscriber,
IssueReaction, IssueReaction,
CommentReaction, CommentReaction,
IssueVote,
) )
from .asset import FileAsset from .asset import FileAsset
@ -72,4 +74,6 @@ from .inbox import Inbox, InboxIssue
from .analytic import AnalyticView from .analytic import AnalyticView
from .notification import Notification from .notification import Notification
from .exporter import ExporterHistory

View File

@ -17,6 +17,7 @@ class Cycle(ProjectBaseModel):
related_name="owned_by_cycle", related_name="owned_by_cycle",
) )
view_props = models.JSONField(default=dict) view_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta: class Meta:
verbose_name = "Cycle" verbose_name = "Cycle"
@ -24,6 +25,17 @@ class Cycle(ProjectBaseModel):
db_table = "cycles" db_table = "cycles"
ordering = ("-created_at",) ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = Cycle.objects.filter(
project=self.project
).aggregate(smallest=models.Min("sort_order"))["smallest"]
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
super(Cycle, self).save(*args, **kwargs)
def __str__(self): def __str__(self):
"""Return name of the cycle""" """Return name of the cycle"""
return f"{self.name} <{self.project.name}>" return f"{self.name} <{self.project.name}>"

View File

@ -0,0 +1,56 @@
import uuid
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
from django.contrib.postgres.fields import ArrayField
# Module imports
from . import BaseModel
def generate_token():
return uuid4().hex
class ExporterHistory(BaseModel):
workspace = models.ForeignKey(
"db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_exporters"
)
project = ArrayField(models.UUIDField(default=uuid.uuid4), blank=True, null=True)
provider = models.CharField(
max_length=50,
choices=(
("json", "json"),
("csv", "csv"),
("xlsx", "xlsx"),
),
)
status = models.CharField(
max_length=50,
choices=(
("queued", "Queued"),
("processing", "Processing"),
("completed", "Completed"),
("failed", "Failed"),
),
default="queued",
)
reason = models.TextField(blank=True)
key = models.TextField(blank=True)
url = models.URLField(max_length=800, blank=True, null=True)
token = models.CharField(max_length=255, default=generate_token, unique=True)
initiated_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="workspace_exporters"
)
class Meta:
verbose_name = "Exporter"
verbose_name_plural = "Exporters"
db_table = "exporters"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the service"""
return f"{self.provider} <{self.workspace.name}>"

View File

@ -108,11 +108,7 @@ class Issue(ProjectBaseModel):
~models.Q(name="Triage"), project=self.project ~models.Q(name="Triage"), project=self.project
).first() ).first()
self.state = random_state self.state = random_state
if random_state.group == "started":
self.start_date = timezone.now().date()
else: else:
if default_state.group == "started":
self.start_date = timezone.now().date()
self.state = default_state self.state = default_state
except ImportError: except ImportError:
pass pass
@ -127,8 +123,6 @@ class Issue(ProjectBaseModel):
PageBlock.objects.filter(issue_id=self.id).filter().update( PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=timezone.now() completed_at=timezone.now()
) )
elif self.state.group == "started":
self.start_date = timezone.now().date()
else: else:
PageBlock.objects.filter(issue_id=self.id).filter().update( PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=None completed_at=None
@ -153,9 +147,6 @@ class Issue(ProjectBaseModel):
if largest_sort_order is not None: if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000 self.sort_order = largest_sort_order + 10000
# If adding it to started state
if self.state.group == "started":
self.start_date = timezone.now().date()
# Strip the html tags using html parser # Strip the html tags using html parser
self.description_stripped = ( self.description_stripped = (
None None
@ -310,6 +301,14 @@ class IssueComment(ProjectBaseModel):
related_name="comments", related_name="comments",
null=True, null=True,
) )
access = models.CharField(
choices=(
("INTERNAL", "INTERNAL"),
("EXTERNAL", "EXTERNAL"),
),
default="INTERNAL",
max_length=100,
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.comment_stripped = ( self.comment_stripped = (
@ -425,13 +424,14 @@ class IssueSubscriber(ProjectBaseModel):
class IssueReaction(ProjectBaseModel): class IssueReaction(ProjectBaseModel):
actor = models.ForeignKey( actor = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="issue_reactions", related_name="issue_reactions",
) )
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_reactions") issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_reactions"
)
reaction = models.CharField(max_length=20) reaction = models.CharField(max_length=20)
class Meta: class Meta:
@ -446,13 +446,14 @@ class IssueReaction(ProjectBaseModel):
class CommentReaction(ProjectBaseModel): class CommentReaction(ProjectBaseModel):
actor = models.ForeignKey( actor = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="comment_reactions", related_name="comment_reactions",
) )
comment = models.ForeignKey(IssueComment, on_delete=models.CASCADE, related_name="comment_reactions") comment = models.ForeignKey(
IssueComment, on_delete=models.CASCADE, related_name="comment_reactions"
)
reaction = models.CharField(max_length=20) reaction = models.CharField(max_length=20)
class Meta: class Meta:
@ -466,6 +467,27 @@ class CommentReaction(ProjectBaseModel):
return f"{self.issue.name} {self.actor.email}" return f"{self.issue.name} {self.actor.email}"
class IssueVote(ProjectBaseModel):
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="votes")
actor = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes"
)
vote = models.IntegerField(
choices=(
(-1, "DOWNVOTE"),
(1, "UPVOTE"),
)
)
class Meta:
unique_together = ["issue", "actor"]
verbose_name = "Issue Vote"
verbose_name_plural = "Issue Votes"
db_table = "issue_votes"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.actor.email}"
# TODO: Find a better method to save the model # TODO: Find a better method to save the model
@receiver(post_save, sender=Issue) @receiver(post_save, sender=Issue)

View File

@ -40,6 +40,7 @@ class Module(ProjectBaseModel):
through_fields=("module", "member"), through_fields=("module", "member"),
) )
view_props = models.JSONField(default=dict) view_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
class Meta: class Meta:
unique_together = ["name", "project"] unique_together = ["name", "project"]
@ -48,6 +49,17 @@ class Module(ProjectBaseModel):
db_table = "modules" db_table = "modules"
ordering = ("-created_at",) ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = Module.objects.filter(
project=self.project
).aggregate(smallest=models.Min("sort_order"))["smallest"]
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
super(Module, self).save(*args, **kwargs)
def __str__(self): def __str__(self):
return f"{self.name} {self.start_date} {self.target_date}" return f"{self.name} {self.start_date} {self.target_date}"

View File

@ -1,3 +1,6 @@
# Python imports
from uuid import uuid4
# Django imports # Django imports
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
@ -31,12 +34,9 @@ def get_default_props():
"showEmptyGroups": True, "showEmptyGroups": True,
} }
def get_default_preferences(): def get_default_preferences():
return { return {"pages": {"block_display": True}}
"pages": {
"block_display": True
}
}
class Project(BaseModel): class Project(BaseModel):
@ -157,7 +157,6 @@ class ProjectMember(ProjectBaseModel):
preferences = models.JSONField(default=get_default_preferences) preferences = models.JSONField(default=get_default_preferences)
sort_order = models.FloatField(default=65535) sort_order = models.FloatField(default=65535)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self._state.adding: if self._state.adding:
smallest_sort_order = ProjectMember.objects.filter( smallest_sort_order = ProjectMember.objects.filter(
@ -217,3 +216,41 @@ class ProjectFavorite(ProjectBaseModel):
def __str__(self): def __str__(self):
"""Return user of the project""" """Return user of the project"""
return f"{self.user.email} <{self.project.name}>" return f"{self.user.email} <{self.project.name}>"
def get_anchor():
return uuid4().hex
def get_default_views():
return {
"list": True,
"kanban": True,
"calendar": True,
"gantt": True,
"spreadsheet": True,
}
class ProjectDeployBoard(ProjectBaseModel):
anchor = models.CharField(
max_length=255, default=get_anchor, unique=True, db_index=True
)
comments = models.BooleanField(default=False)
reactions = models.BooleanField(default=False)
inbox = models.ForeignKey(
"db.Inbox", related_name="bord_inbox", on_delete=models.SET_NULL, null=True
)
votes = models.BooleanField(default=False)
views = models.JSONField(default=get_default_views)
class Meta:
unique_together = ["project", "anchor"]
verbose_name = "Project Deploy Board"
verbose_name_plural = "Project Deploy Boards"
db_table = "project_deploy_boards"
ordering = ("-created_at",)
def __str__(self):
"""Return project and anchor"""
return f"{self.anchor} <{self.project.name}>"

View File

@ -1,6 +1,7 @@
# Python imports # Python imports
from enum import unique
import uuid import uuid
import string
import random
# Django imports # Django imports
from django.db import models from django.db import models
@ -18,6 +19,7 @@ from sentry_sdk import capture_exception
from slack_sdk import WebClient from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError from slack_sdk.errors import SlackApiError
def get_default_onboarding(): def get_default_onboarding():
return { return {
"profile_complete": False, "profile_complete": False,
@ -26,6 +28,7 @@ def get_default_onboarding():
"workspace_join": False, "workspace_join": False,
} }
class User(AbstractBaseUser, PermissionsMixin): class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField( id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True 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) role = models.CharField(max_length=300, null=True, blank=True)
is_bot = models.BooleanField(default=False) is_bot = models.BooleanField(default=False)
theme = models.JSONField(default=dict) theme = models.JSONField(default=dict)
display_name = models.CharField(max_length=255, default="")
is_tour_completed = models.BooleanField(default=False) is_tour_completed = models.BooleanField(default=False)
onboarding_step = models.JSONField(default=get_default_onboarding) 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 = uuid.uuid4().hex + uuid.uuid4().hex
self.token_updated_at = timezone.now() 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: if self.is_superuser:
self.is_staff = True self.is_staff = True

View File

@ -33,6 +33,7 @@ def get_default_props():
"estimate": True, "estimate": True,
"created_on": True, "created_on": True,
"updated_on": True, "updated_on": True,
"start_date": True,
}, },
"showEmptyGroups": True, "showEmptyGroups": True,
} }

View File

@ -214,4 +214,4 @@ SIMPLE_JWT = {
CELERY_TIMEZONE = TIME_ZONE CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = 'json' CELERY_TASK_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['application/json'] CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task",) CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task","plane.bgtasks.exporter_expired_task")

View File

@ -124,10 +124,11 @@ def filter_created_at(params, filter, method):
else: else:
if params.get("created_at", None) and len(params.get("created_at")): if params.get("created_at", None) and len(params.get("created_at")):
for query in params.get("created_at"): for query in params.get("created_at"):
if query.get("timeline", "after") == "after": created_at_query = query.split(";")
filter["created_at__date__gte"] = query.get("datetime") if len(created_at_query) == 2 and "after" in created_at_query:
filter["created_at__date__gte"] = created_at_query[0]
else: else:
filter["created_at__date__lte"] = query.get("datetime") filter["created_at__date__lte"] = created_at_query[0]
return filter return filter
@ -144,10 +145,11 @@ def filter_updated_at(params, filter, method):
else: else:
if params.get("updated_at", None) and len(params.get("updated_at")): if params.get("updated_at", None) and len(params.get("updated_at")):
for query in params.get("updated_at"): for query in params.get("updated_at"):
if query.get("timeline", "after") == "after": updated_at_query = query.split(";")
filter["updated_at__date__gte"] = query.get("datetime") if len(updated_at_query) == 2 and "after" in updated_at_query:
filter["updated_at__date__gte"] = updated_at_query[0]
else: else:
filter["updated_at__date__lte"] = query.get("datetime") filter["updated_at__date__lte"] = updated_at_query[0]
return filter return filter
@ -164,10 +166,11 @@ def filter_start_date(params, filter, method):
else: else:
if params.get("start_date", None) and len(params.get("start_date")): if params.get("start_date", None) and len(params.get("start_date")):
for query in params.get("start_date"): for query in params.get("start_date"):
if query.get("timeline", "after") == "after": start_date_query = query.split(";")
filter["start_date__gte"] = query.get("datetime") if len(start_date_query) == 2 and "after" in start_date_query:
filter["start_date__gte"] = start_date_query[0]
else: else:
filter["start_date__lte"] = query.get("datetime") filter["start_date__lte"] = start_date_query[0]
return filter return filter
@ -184,10 +187,11 @@ def filter_target_date(params, filter, method):
else: else:
if params.get("target_date", None) and len(params.get("target_date")): if params.get("target_date", None) and len(params.get("target_date")):
for query in params.get("target_date"): for query in params.get("target_date"):
if query.get("timeline", "after") == "after": target_date_query = query.split(";")
filter["target_date__gt"] = query.get("datetime") if len(target_date_query) == 2 and "after" in target_date_query:
filter["target_date__gt"] = target_date_query[0]
else: else:
filter["target_date__lt"] = query.get("datetime") filter["target_date__lt"] = target_date_query[0]
return filter return filter
@ -205,10 +209,11 @@ def filter_completed_at(params, filter, method):
else: else:
if params.get("completed_at", None) and len(params.get("completed_at")): if params.get("completed_at", None) and len(params.get("completed_at")):
for query in params.get("completed_at"): for query in params.get("completed_at"):
if query.get("timeline", "after") == "after": completed_at_query = query.split(";")
filter["completed_at__date__gte"] = query.get("datetime") if len(completed_at_query) == 2 and "after" in completed_at_query:
filter["completed_at__date__gte"] = completed_at_query[0]
else: else:
filter["completed_at__lte"] = query.get("datetime") filter["completed_at__lte"] = completed_at_query[0]
return filter return filter
@ -292,9 +297,16 @@ def filter_subscribed_issues(params, filter, method):
return filter return filter
def filter_start_target_date_issues(params, filter, method):
start_target_date = params.get("start_target_date", "false")
if start_target_date == "true":
filter["target_date__isnull"] = False
filter["start_date__isnull"] = False
return filter
def issue_filters(query_params, method): def issue_filters(query_params, method):
filter = dict() filter = dict()
print(query_params)
ISSUE_FILTER = { ISSUE_FILTER = {
"state": filter_state, "state": filter_state,
@ -318,6 +330,7 @@ def issue_filters(query_params, method):
"inbox_status": filter_inbox_status, "inbox_status": filter_inbox_status,
"sub_issue": filter_sub_issue_toggle, "sub_issue": filter_sub_issue_toggle,
"subscriber": filter_subscribed_issues, "subscriber": filter_subscribed_issues,
"start_target_date": filter_start_target_date_issues,
} }
for key, value in ISSUE_FILTER.items(): for key, value in ISSUE_FILTER.items():

View File

@ -32,4 +32,5 @@ celery==5.3.1
django_celery_beat==2.5.0 django_celery_beat==2.5.0
psycopg-binary==3.1.9 psycopg-binary==3.1.9
psycopg-c==3.1.9 psycopg-c==3.1.9
scout-apm==2.26.1 scout-apm==2.26.1
openpyxl==3.1.2

View File

@ -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>

View File

@ -14,17 +14,17 @@ type Props = {
export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) => { export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) => {
let tooltipValue: string | number = ""; 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 (params.segment) {
if (DATE_KEYS.includes(params.segment)) tooltipValue = renderMonthAndYear(datum.id); if (DATE_KEYS.includes(params.segment)) tooltipValue = renderMonthAndYear(datum.id);
else if (params.segment === "assignees__email") { else tooltipValue = datum.id;
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 { } else {
if (DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue; if (DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue;
else tooltipValue = datum.id === "count" ? "Issue count" : "Estimate"; 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>
<span>{datum.value}</span> <span>{datum.value}</span>
</div> </div>

View File

@ -29,6 +29,14 @@ export const AnalyticsGraph: React.FC<Props> = ({
yAxisKey, yAxisKey,
fullScreen, 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 = () => { const generateYAxisTickValues = () => {
if (!analytics) return []; if (!analytics) return [];
@ -70,17 +78,17 @@ export const AnalyticsGraph: React.FC<Props> = ({
height={fullScreen ? "400px" : "300px"} height={fullScreen ? "400px" : "300px"}
margin={{ margin={{
right: 20, 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={{ axisBottom={{
tickSize: 0, tickSize: 0,
tickPadding: 10, tickPadding: 10,
tickRotation: barGraphData.data.length > 7 ? -45 : 0, tickRotation: barGraphData.data.length > 7 ? -45 : 0,
renderTick: renderTick:
params.x_axis === "assignees__email" params.x_axis === "assignees__id"
? (datum) => { ? (datum) => {
const avatar = analytics.extras.assignee_details?.find( const avatar = analytics.extras.assignee_details?.find(
(a) => a?.assignees__email === datum?.value (a) => a?.assignees__display_name === datum?.value
)?.assignees__avatar; )?.assignees__avatar;
if (avatar && avatar !== "") if (avatar && avatar !== "")
@ -101,7 +109,11 @@ export const AnalyticsGraph: React.FC<Props> = ({
<g transform={`translate(${datum.x},${datum.y})`}> <g transform={`translate(${datum.x},${datum.y})`}>
<circle cy={18} r={8} fill="#374151" /> <circle cy={18} r={8} fill="#374151" />
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff"> <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] ? `${datum.value}`.toUpperCase()[0]
: "?"} : "?"}
</text> </text>

View File

@ -277,9 +277,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6> <h6 className="text-custom-text-200">Lead</h6>
<span> <span>{cycleDetails.owned_by?.display_name}</span>
{cycleDetails.owned_by?.first_name} {cycleDetails.owned_by?.last_name}
</span>
</div> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6> <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="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6> <h6 className="text-custom-text-200">Lead</h6>
<span> <span>{moduleDetails.lead_detail?.display_name}</span>
{moduleDetails.lead_detail?.first_name}{" "}
{moduleDetails.lead_detail?.last_name}
</span>
</div> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6> <h6 className="text-custom-text-200">Start Date</h6>

View File

@ -22,15 +22,12 @@ type Props = {
}; };
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => { export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => {
const renderAssigneeName = (email: string): string => { const renderAssigneeName = (assigneeId: string): string => {
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__email === email); const assignee = analytics.extras.assignee_details.find((a) => a.assignees__id === assigneeId);
if (!assignee) return "No assignee"; if (!assignee) return "No assignee";
if (assignee.assignees__first_name !== "") return assignee.assignees__display_name || "No assignee";
return assignee.assignees__first_name + " " + assignee.assignees__last_name;
return email;
}; };
return ( return (
@ -65,10 +62,10 @@ export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, param
}} }}
/> />
)} )}
{DATE_KEYS.includes(params.segment ?? "") {params.segment === "assignees__id"
? renderMonthAndYear(key)
: params.segment === "assignees__email"
? renderAssigneeName(key) ? renderAssigneeName(key)
: DATE_KEYS.includes(params.segment ?? "")
? renderMonthAndYear(key)
: key} : key}
</div> </div>
</th> </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}`) ? renderAssigneeName(`${item.name}`)
: addSpaceIfCamelCase(`${item.name}`)} : addSpaceIfCamelCase(`${item.name}`)}
</td> </td>

View File

@ -1,22 +1,27 @@
type Props = { type Props = {
users: { users: {
avatar: string | null; avatar: string | null;
email: string | null; display_name: string | null;
firstName: string; firstName: string;
lastName: string; lastName: string;
count: number; count: number;
id: string;
}[]; }[];
title: string; title: string;
workspaceSlug: string;
}; };
export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => ( export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title, workspaceSlug }) => (
<div className="p-3 border border-custom-border-200 rounded-[10px]"> <div className="p-3 border border-custom-border-200 rounded-[10px]">
<h6 className="text-base font-medium">{title}</h6> <h6 className="text-base font-medium">{title}</h6>
{users.length > 0 ? ( {users.length > 0 ? (
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
{users.map((user) => ( {users.map((user) => (
<div <a
key={user.email ?? "None"} key={user.display_name ?? "None"}
href={`/${workspaceSlug}/profile/${user.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-start justify-between gap-4 text-xs" className="flex items-start justify-between gap-4 text-xs"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -25,20 +30,20 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
<img <img
src={user.avatar} src={user.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded-full" 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>
) : ( ) : (
<div className="grid place-items-center flex-shrink-0 rounded-full bg-gray-700 text-[11px] capitalize text-white h-4 w-4"> <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> </div>
)} )}
<span className="break-words text-custom-text-200"> <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> </span>
</div> </div>
<span className="flex-shrink-0">{user.count}</span> <span className="flex-shrink-0">{user.count}</span>
</div> </a>
))} ))}
</div> </div>
) : ( ) : (

View File

@ -56,22 +56,26 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
<AnalyticsLeaderboard <AnalyticsLeaderboard
users={defaultAnalytics.most_issue_created_user?.map((user) => ({ users={defaultAnalytics.most_issue_created_user?.map((user) => ({
avatar: user?.created_by__avatar, avatar: user?.created_by__avatar,
email: user?.created_by__email,
firstName: user?.created_by__first_name, firstName: user?.created_by__first_name,
lastName: user?.created_by__last_name, lastName: user?.created_by__last_name,
display_name: user?.created_by__display_name,
count: user?.count, count: user?.count,
id: user?.created_by__id,
}))} }))}
title="Most issues created" title="Most issues created"
workspaceSlug={workspaceSlug?.toString() ?? ""}
/> />
<AnalyticsLeaderboard <AnalyticsLeaderboard
users={defaultAnalytics.most_issue_closed_user?.map((user) => ({ users={defaultAnalytics.most_issue_closed_user?.map((user) => ({
avatar: user?.assignees__avatar, avatar: user?.assignees__avatar,
email: user?.assignees__email,
firstName: user?.assignees__first_name, firstName: user?.assignees__first_name,
lastName: user?.assignees__last_name, lastName: user?.assignees__last_name,
display_name: user?.assignees__display_name,
count: user?.count, count: user?.count,
id: user?.assignees__id,
}))} }))}
title="Most issues closed" title="Most issues closed"
workspaceSlug={workspaceSlug?.toString() ?? ""}
/> />
<div className={fullScreen ? "md:col-span-2" : ""}> <div className={fullScreen ? "md:col-span-2" : ""}>
<AnalyticsYearWiseIssues defaultAnalytics={defaultAnalytics} /> <AnalyticsYearWiseIssues defaultAnalytics={defaultAnalytics} />

View File

@ -16,23 +16,20 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
{defaultAnalytics.pending_issue_user.length > 0 ? ( {defaultAnalytics.pending_issue_user.length > 0 ? (
<BarGraph <BarGraph
data={defaultAnalytics.pending_issue_user} data={defaultAnalytics.pending_issue_user}
indexBy="assignees__email" indexBy="assignees__display_name"
keys={["count"]} keys={["count"]}
height="250px" height="250px"
colors={() => `#f97316`} colors={() => `#f97316`}
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)} customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)}
tooltip={(datum) => { tooltip={(datum) => {
const assignee = defaultAnalytics.pending_issue_user.find( const assignee = defaultAnalytics.pending_issue_user.find(
(a) => a.assignees__email === `${datum.indexValue}` (a) => a.assignees__display_name === `${datum.indexValue}`
); );
return ( return (
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs"> <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"> <span className="font-medium text-custom-text-200">
{assignee {assignee ? assignee.assignees__display_name : "No assignee"}:{" "}
? assignee.assignees__first_name + " " + assignee.assignees__last_name
: "No assignee"}
:{" "}
</span> </span>
{datum.value} {datum.value}
</div> </div>

View File

@ -7,6 +7,8 @@ import { useTheme } from "next-themes";
import { SettingIcon } from "components/icons"; import { SettingIcon } from "components/icons";
import userService from "services/user.service"; import userService from "services/user.service";
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
// helper
import { unsetCustomCssVariables } from "helpers/theme.helper";
type Props = { type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>; setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
@ -22,15 +24,17 @@ export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
const updateUserTheme = (newTheme: string) => { const updateUserTheme = (newTheme: string) => {
if (!user) return; if (!user) return;
unsetCustomCssVariables();
setTheme(newTheme); setTheme(newTheme);
mutateUser((prevData) => { mutateUser((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { return {
...prevData, ...prevData,
theme: { theme: {
...prevData.theme, ...prevData?.theme,
theme: newTheme, theme: newTheme,
}, },
}; };

View File

@ -354,8 +354,8 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Item <Command.Item
key={item.id} key={item.id}
onSelect={() => { onSelect={() => {
router.push(currentSection.path(item));
setIsPaletteOpen(false); setIsPaletteOpen(false);
router.push(currentSection.path(item));
}} }}
value={`${key}-${item?.name}`} value={`${key}-${item?.name}`}
className="focus:outline-none" className="focus:outline-none"
@ -379,6 +379,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Group heading="Issue actions"> <Command.Group heading="Issue actions">
<Command.Item <Command.Item
onSelect={() => { onSelect={() => {
setIsPaletteOpen(false);
setPlaceholder("Change state..."); setPlaceholder("Change state...");
setSearchTerm(""); setSearchTerm("");
setPages([...pages, "change-issue-state"]); setPages([...pages, "change-issue-state"]);
@ -460,6 +461,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Group heading="Issue"> <Command.Group heading="Issue">
<Command.Item <Command.Item
onSelect={() => { onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", { const e = new KeyboardEvent("keydown", {
key: "c", key: "c",
}); });
@ -479,6 +481,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Group heading="Project"> <Command.Group heading="Project">
<Command.Item <Command.Item
onSelect={() => { onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", { const e = new KeyboardEvent("keydown", {
key: "p", key: "p",
}); });
@ -500,6 +503,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Group heading="Cycle"> <Command.Group heading="Cycle">
<Command.Item <Command.Item
onSelect={() => { onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", { const e = new KeyboardEvent("keydown", {
key: "q", key: "q",
}); });
@ -517,6 +521,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Group heading="Module"> <Command.Group heading="Module">
<Command.Item <Command.Item
onSelect={() => { onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", { const e = new KeyboardEvent("keydown", {
key: "m", key: "m",
}); });
@ -534,6 +539,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Group heading="View"> <Command.Group heading="View">
<Command.Item <Command.Item
onSelect={() => { onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", { const e = new KeyboardEvent("keydown", {
key: "v", key: "v",
}); });
@ -551,6 +557,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
<Command.Group heading="Page"> <Command.Group heading="Page">
<Command.Item <Command.Item
onSelect={() => { onSelect={() => {
setIsPaletteOpen(false);
const e = new KeyboardEvent("keydown", { const e = new KeyboardEvent("keydown", {
key: "d", key: "d",
}); });
@ -568,11 +575,12 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
{projectDetails && projectDetails.inbox_view && ( {projectDetails && projectDetails.inbox_view && (
<Command.Group heading="Inbox"> <Command.Group heading="Inbox">
<Command.Item <Command.Item
onSelect={() => onSelect={() => {
setIsPaletteOpen(false);
redirect( redirect(
`/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}` `/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}`
) );
} }}
className="focus:outline-none" className="focus:outline-none"
> >
<div className="flex items-center gap-2 text-custom-text-200"> <div className="flex items-center gap-2 text-custom-text-200">
@ -731,12 +739,21 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
</div> </div>
</Command.Item> </Command.Item>
<Command.Item <Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/import-export`)} onSelect={() => redirect(`/${workspaceSlug}/settings/imports`)}
className="focus:outline-none" className="focus:outline-none"
> >
<div className="flex items-center gap-2 text-custom-text-200"> <div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" /> <SettingIcon className="h-4 w-4 text-custom-text-200" />
Import/Export Import
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/exports`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Export
</div> </div>
</Command.Item> </Command.Item>
</> </>

View File

@ -1,11 +1,8 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// hooks // hooks
import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
// components // components
@ -24,8 +21,14 @@ import issuesService from "services/issues.service";
import inboxService from "services/inbox.service"; import inboxService from "services/inbox.service";
// fetch keys // fetch keys
import { INBOX_LIST, ISSUE_DETAILS } from "constants/fetch-keys"; import { INBOX_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { observable } from "mobx";
import { observer } from "mobx-react-lite";
export const CommandPalette: React.FC = observer(() => {
const store: any = useMobxStore();
export const CommandPalette: React.FC = () => {
const [isPaletteOpen, setIsPaletteOpen] = useState(false); const [isPaletteOpen, setIsPaletteOpen] = useState(false);
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false); const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
@ -43,13 +46,12 @@ export const CommandPalette: React.FC = () => {
const { user } = useUser(); const { user } = useUser();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { toggleCollapsed } = useTheme();
const { data: issueDetails } = useSWR( const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null, workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId workspaceSlug && projectId && issueId
? () => ? () =>
issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string) issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
: null : null
); );
@ -74,53 +76,52 @@ export const CommandPalette: React.FC = () => {
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
const singleShortcutKeys = ["p", "v", "d", "h", "q", "m"];
const { key, ctrlKey, metaKey, altKey, shiftKey } = e; const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
if (!key) return; if (!key) return;
const keyPressed = key.toLowerCase(); const keyPressed = key.toLowerCase();
const cmdClicked = ctrlKey || metaKey;
// if on input, textarea or editor, don't do anything
if ( if (
!(e.target instanceof HTMLTextAreaElement) && e.target instanceof HTMLTextAreaElement ||
!(e.target instanceof HTMLInputElement) && e.target instanceof HTMLInputElement ||
!(e.target as Element).classList?.contains("remirror-editor") (e.target as Element).classList?.contains("ProseMirror")
) { )
if ((ctrlKey || metaKey) && keyPressed === "k") { return;
e.preventDefault();
setIsPaletteOpen(true); if (cmdClicked) {
} else if ((ctrlKey || metaKey) && keyPressed === "c") { if (keyPressed === "k") {
if (altKey) { e.preventDefault();
setIsPaletteOpen(true);
} else if (keyPressed === "c" && altKey) {
e.preventDefault(); e.preventDefault();
copyIssueUrlToClipboard(); copyIssueUrlToClipboard();
} else if (keyPressed === "b") {
e.preventDefault();
store.theme.setSidebarCollapsed(!store?.theme?.sidebarCollapsed);
}
} else {
if (keyPressed === "c") {
setIsIssueModalOpen(true);
} else if (keyPressed === "p") {
setIsProjectModalOpen(true);
} else if (keyPressed === "v") {
setIsCreateViewModalOpen(true);
} else if (keyPressed === "d") {
setIsCreateUpdatePageModalOpen(true);
} else if (keyPressed === "h") {
setIsShortcutsModalOpen(true);
} else if (keyPressed === "q") {
setIsCreateCycleModalOpen(true);
} else if (keyPressed === "m") {
setIsCreateModuleModalOpen(true);
} else if (keyPressed === "backspace" || keyPressed === "delete") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
} }
} else if (keyPressed === "c") {
e.preventDefault();
setIsIssueModalOpen(true);
} else if ((ctrlKey || metaKey) && 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 === "p") {
setIsProjectModalOpen(true);
} else if (keyPressed === "v") {
setIsCreateViewModalOpen(true);
} else if (keyPressed === "d") {
setIsCreateUpdatePageModalOpen(true);
} else if (keyPressed === "h") {
setIsShortcutsModalOpen(true);
} else if (keyPressed === "q") {
setIsCreateCycleModalOpen(true);
} else if (keyPressed === "m") {
setIsCreateModuleModalOpen(true);
} }
}
}, },
[toggleCollapsed, copyIssueUrlToClipboard] [copyIssueUrlToClipboard]
); );
useEffect(() => { useEffect(() => {
@ -195,4 +196,4 @@ export const CommandPalette: React.FC = () => {
/> />
</> </>
); );
}; })

View File

@ -34,15 +34,12 @@ export const ChangeIssueAssignee: React.FC<Props> = ({ setIsPaletteOpen, issue,
const options = const options =
members?.map(({ member }) => ({ members?.map(({ member }) => ({
value: member.id, value: member.id,
query: query: member.display_name,
(member.first_name && member.first_name !== "" ? member.first_name : member.email) +
" " +
member.last_name ?? "",
content: ( content: (
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar user={member} /> <Avatar user={member} />
{member.first_name && member.first_name !== "" ? member.first_name : member.email} {member.display_name}
</div> </div>
{issue.assignees.includes(member.id) && ( {issue.assignees.includes(member.id) && (
<div> <div>

View File

@ -35,6 +35,22 @@ const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
); );
}; };
const UserLink = ({ activity }: { activity: IIssueActivity }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<a
href={`/${workspaceSlug}/profile/${activity.new_identifier ?? activity.old_identifier}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center hover:underline"
>
{activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value}
</a>
);
};
const activityDetails: { const activityDetails: {
[key: string]: { [key: string]: {
message: (activity: IIssueActivity, showIssue: boolean) => React.ReactNode; message: (activity: IIssueActivity, showIssue: boolean) => React.ReactNode;
@ -46,8 +62,7 @@ const activityDetails: {
if (activity.old_value === "") if (activity.old_value === "")
return ( return (
<> <>
added a new assignee{" "} added a new assignee <UserLink activity={activity} />
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
{showIssue && ( {showIssue && (
<> <>
{" "} {" "}
@ -60,8 +75,7 @@ const activityDetails: {
else else
return ( return (
<> <>
removed the assignee{" "} removed the assignee <UserLink activity={activity} />
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
{showIssue && ( {showIssue && (
<> <>
{" "} {" "}
@ -428,6 +442,40 @@ const activityDetails: {
), ),
icon: <Icon iconName="signal_cellular_alt" className="!text-sm" aria-hidden="true" />, 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: { state: {
message: (activity, showIssue) => ( message: (activity, showIssue) => (
<> <>

View File

@ -157,10 +157,10 @@ export const FiltersList: React.FC<Props> = ({
return ( return (
<div <div
key={memberId} 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} /> <Avatar user={member} />
<span>{member?.first_name}</span> <span>{member?.display_name}</span>
<span <span
className="cursor-pointer" className="cursor-pointer"
onClick={() => 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" className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
> >
<Avatar user={member} /> <Avatar user={member} />
<span>{member?.first_name}</span> <span>{member?.display_name}</span>
<span <span
className="cursor-pointer" className="cursor-pointer"
onClick={() => onClick={() =>

View File

@ -113,49 +113,51 @@ export const IssuesFilterView: React.FC = () => {
))} ))}
</div> </div>
)} )}
<SelectFilters {issueView !== "gantt_chart" && (
filters={filters} <SelectFilters
onSelect={(option) => { filters={filters}
const key = option.key as keyof typeof filters; onSelect={(option) => {
const key = option.key as keyof typeof filters;
if (key === "target_date") { if (key === "target_date") {
const valueExists = checkIfArraysHaveSameElements( const valueExists = checkIfArraysHaveSameElements(
filters.target_date ?? [], filters.target_date ?? [],
option.value option.value
);
setFilters({
target_date: valueExists ? null : option.value,
});
} else {
const valueExists = filters[key]?.includes(option.value);
if (valueExists)
setFilters(
{
[option.key]: ((filters[key] ?? []) as any[])?.filter(
(val) => val !== option.value
),
},
!Boolean(viewId)
); );
else
setFilters( setFilters({
{ target_date: valueExists ? null : option.value,
[option.key]: [...((filters[key] ?? []) as any[]), option.value], });
}, } else {
!Boolean(viewId) const valueExists = filters[key]?.includes(option.value);
);
} if (valueExists)
}} setFilters(
direction="left" {
height="rg" [option.key]: ((filters[key] ?? []) as any[])?.filter(
/> (val) => val !== option.value
),
},
!Boolean(viewId)
);
else
setFilters(
{
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
},
!Boolean(viewId)
);
}
}}
direction="left"
height="rg"
/>
)}
<Popover className="relative"> <Popover className="relative">
{({ open }) => ( {({ open }) => (
<> <>
<Popover.Button <Popover.Button
className={`group flex items-center gap-2 rounded-md border border-custom-sidebar-border-200 bg-transparent px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${ className={`group flex items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
open open
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100" ? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
: "text-custom-sidebar-text-200" : "text-custom-sidebar-text-200"
@ -177,8 +179,9 @@ export const IssuesFilterView: React.FC = () => {
<Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-custom-border-200 bg-custom-background-90 p-3 shadow-lg"> <Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-custom-border-200 bg-custom-background-90 p-3 shadow-lg">
<div className="relative divide-y-2 divide-custom-border-200"> <div className="relative divide-y-2 divide-custom-border-200">
<div className="space-y-4 pb-3 text-xs"> <div className="space-y-4 pb-3 text-xs">
{issueView !== "calendar" && issueView !== "spreadsheet" && ( {issueView !== "calendar" &&
<> issueView !== "spreadsheet" &&
issueView !== "gantt_chart" && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Group by</h4> <h4 className="text-custom-text-200">Group by</h4>
<div className="w-28"> <div className="w-28">
@ -206,34 +209,34 @@ export const IssuesFilterView: React.FC = () => {
</CustomMenu> </CustomMenu>
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> )}
<h4 className="text-custom-text-200">Order by</h4> {issueView !== "calendar" && issueView !== "spreadsheet" && (
<div className="w-28"> <div className="flex items-center justify-between">
<CustomMenu <h4 className="text-custom-text-200">Order by</h4>
label={ <div className="w-28">
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ?? <CustomMenu
"Select" label={
} ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
className="!w-full" "Select"
buttonClassName="w-full" }
> className="!w-full"
{ORDER_BY_OPTIONS.map((option) => buttonClassName="w-full"
groupByProperty === "priority" && >
option.key === "priority" ? null : ( {ORDER_BY_OPTIONS.map((option) =>
<CustomMenu.MenuItem groupByProperty === "priority" && option.key === "priority" ? null : (
key={option.key} <CustomMenu.MenuItem
onClick={() => { key={option.key}
setOrderBy(option.key); onClick={() => {
}} setOrderBy(option.key);
> }}
{option.name} >
</CustomMenu.MenuItem> {option.name}
) </CustomMenu.MenuItem>
)} )
</CustomMenu> )}
</div> </CustomMenu>
</div> </div>
</> </div>
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4> <h4 className="text-custom-text-200">Issue type</h4>
@ -263,16 +266,19 @@ export const IssuesFilterView: React.FC = () => {
</div> </div>
{issueView !== "calendar" && issueView !== "spreadsheet" && ( {issueView !== "calendar" && issueView !== "spreadsheet" && (
<> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <h4 className="text-custom-text-200">Show sub-issues</h4>
<h4 className="text-custom-text-200">Show sub-issues</h4> <div className="w-28">
<div className="w-28"> <ToggleSwitch
<ToggleSwitch value={showSubIssues}
value={showSubIssues} onChange={() => setShowSubIssues(!showSubIssues)}
onChange={() => setShowSubIssues(!showSubIssues)} />
/>
</div>
</div> </div>
</div>
)}
{issueView !== "calendar" &&
issueView !== "spreadsheet" &&
issueView !== "gantt_chart" && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty states</h4> <h4 className="text-custom-text-200">Show empty states</h4>
<div className="w-28"> <div className="w-28">
@ -282,6 +288,10 @@ export const IssuesFilterView: React.FC = () => {
/> />
</div> </div>
</div> </div>
)}
{issueView !== "calendar" &&
issueView !== "spreadsheet" &&
issueView !== "gantt_chart" && (
<div className="relative flex justify-end gap-x-3"> <div className="relative flex justify-end gap-x-3">
<button type="button" onClick={() => resetFilterToDefault()}> <button type="button" onClick={() => resetFilterToDefault()}>
Reset to default Reset to default
@ -294,47 +304,48 @@ export const IssuesFilterView: React.FC = () => {
Set as default Set as default
</button> </button>
</div> </div>
</> )}
)}
</div> </div>
<div className="space-y-2 py-3"> {issueView !== "gantt_chart" && (
<h4 className="text-sm text-custom-text-200">Display Properties</h4> <div className="space-y-2 py-3">
<div className="flex flex-wrap items-center gap-2 text-custom-text-200"> <h4 className="text-sm text-custom-text-200">Display Properties</h4>
{Object.keys(properties).map((key) => { <div className="flex flex-wrap items-center gap-2 text-custom-text-200">
if (key === "estimate" && !isEstimateActive) return null; {Object.keys(properties).map((key) => {
if (key === "estimate" && !isEstimateActive) return null;
if ( if (
issueView === "spreadsheet" && issueView === "spreadsheet" &&
(key === "attachment_count" || (key === "attachment_count" ||
key === "link" || key === "link" ||
key === "sub_issue_count") key === "sub_issue_count")
) )
return null; return null;
if ( if (
issueView !== "spreadsheet" && issueView !== "spreadsheet" &&
(key === "created_on" || key === "updated_on") (key === "created_on" || key === "updated_on")
) )
return null; return null;
return ( return (
<button <button
key={key} key={key}
type="button" type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${ className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties] properties[key as keyof Properties]
? "border-custom-primary bg-custom-primary text-white" ? "border-custom-primary bg-custom-primary text-white"
: "border-custom-border-200" : "border-custom-border-200"
}`} }`}
onClick={() => setProperties(key as keyof Properties)} onClick={() => setProperties(key as keyof Properties)}
> >
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)} {key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button> </button>
); );
})} })}
</div>
</div> </div>
</div> )}
</div> </div>
</Popover.Panel> </Popover.Panel>
</Transition> </Transition>

View File

@ -27,8 +27,8 @@ const unsplashEnabled =
const tabOptions = [ const tabOptions = [
{ {
key: "unsplash", key: "images",
title: "Unsplash", title: "Images",
}, },
{ {
key: "upload", key: "upload",

View File

@ -1,7 +1,6 @@
import { useEffect, useState, forwardRef, useRef } from "react"; import React, { useEffect, useState, forwardRef, useRef } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import dynamic from "next/dynamic";
// react-hook-form // react-hook-form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -15,6 +14,7 @@ import useUserAuth from "hooks/use-user-auth";
import { Input, PrimaryButton, SecondaryButton } from "components/ui"; import { Input, PrimaryButton, SecondaryButton } from "components/ui";
import { IIssue, IPageBlock } from "types"; import { IIssue, IPageBlock } from "types";
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
@ -32,17 +32,11 @@ type FormData = {
task: string; task: string;
}; };
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
ssr: false, (props, ref) => <Tiptap {...props} forwardedRef={ref} />
});
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
const WrappedRemirrorRichTextEditor = forwardRef<IRemirrorRichTextEditor, IRemirrorRichTextEditor>(
(props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />
); );
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor"; TiptapEditor.displayName = "TiptapEditor";
export const GptAssistantModal: React.FC<Props> = ({ export const GptAssistantModal: React.FC<Props> = ({
isOpen, isOpen,
@ -151,10 +145,10 @@ export const GptAssistantModal: React.FC<Props> = ({
}`} }`}
> >
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && ( {((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
<div className="remirror-section text-sm"> <div id="tiptap-container" className="text-sm">
Content: Content:
<WrappedRemirrorRichTextEditor <TiptapEditor
value={htmlContent ?? <p>{content}</p>} value={htmlContent ?? `<p>${content}</p>`}
customClassName="-m-3" customClassName="-m-3"
noBorder noBorder
borderOnFocus={false} borderOnFocus={false}
@ -166,7 +160,7 @@ export const GptAssistantModal: React.FC<Props> = ({
{response !== "" && ( {response !== "" && (
<div className="page-block-section text-sm"> <div className="page-block-section text-sm">
Response: Response:
<RemirrorRichTextEditor <Tiptap
value={`<p>${response}</p>`} value={`<p>${response}</p>`}
customClassName="-mx-3 -my-3" customClassName="-mx-3 -my-3"
noBorder noBorder

View File

@ -131,10 +131,10 @@ export const ImageUploadModal: React.FC<Props> = ({
Upload Image Upload Image
</Dialog.Title> </Dialog.Title>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center justify-center gap-3">
<div <div
{...getRootProps()} {...getRootProps()}
className={`relative grid h-80 w-full cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-custom-primary focus:ring-offset-2 ${ className={`relative grid h-80 w-80 cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-custom-primary focus:ring-offset-2 ${
(image === null && isDragActive) || !value (image === null && isDragActive) || !value
? "border-2 border-dashed border-custom-border-200 hover:bg-custom-background-90" ? "border-2 border-dashed border-custom-border-200 hover:bg-custom-background-90"
: "" : ""

View File

@ -62,7 +62,7 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
by{" "} by{" "}
{link.created_by_detail.is_bot {link.created_by_detail.is_bot
? link.created_by_detail.first_name + " Bot" ? link.created_by_detail.first_name + " Bot"
: link.created_by_detail.email} : link.created_by_detail.display_name}
</p> </p>
</div> </div>
</a> </a>

View File

@ -133,9 +133,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
avatar: assignee.avatar ?? "", avatar: assignee.avatar ?? "",
first_name: assignee.first_name ?? "", first_name: assignee.first_name ?? "",
last_name: assignee.last_name ?? "", last_name: assignee.last_name ?? "",
display_name: assignee.display_name ?? "",
}} }}
/> />
<span>{assignee.first_name}</span> <span>{assignee.display_name}</span>
</div> </div>
} }
completed={assignee.completed_issues} completed={assignee.completed_issues}

View File

@ -21,13 +21,21 @@ import { ICustomTheme } from "types";
type Props = { type Props = {
name: keyof ICustomTheme; name: keyof ICustomTheme;
position?: "left" | "right";
watch: UseFormWatch<any>; watch: UseFormWatch<any>;
setValue: UseFormSetValue<any>; setValue: UseFormSetValue<any>;
error: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined; error: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined;
register: UseFormRegister<any>; register: UseFormRegister<any>;
}; };
export const ColorPickerInput: React.FC<Props> = ({ name, watch, setValue, error, register }) => { export const ColorPickerInput: React.FC<Props> = ({
name,
position = "left",
watch,
setValue,
error,
register,
}) => {
const handleColorChange = (newColor: ColorResult) => { const handleColorChange = (newColor: ColorResult) => {
const { hex } = newColor; const { hex } = newColor;
setValue(name, hex); setValue(name, hex);
@ -104,7 +112,11 @@ export const ColorPickerInput: React.FC<Props> = ({ name, watch, setValue, error
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<Popover.Panel className="absolute bottom-8 right-0 z-20 mt-1 max-w-xs px-2 sm:px-0"> <Popover.Panel
className={`absolute bottom-8 z-20 mt-1 max-w-xs px-2 sm:px-0 ${
position === "right" ? "left-0" : "right-0"
}`}
>
<SketchPicker color={watch(name)} onChange={handleColorChange} /> <SketchPicker color={watch(name)} onChange={handleColorChange} />
</Popover.Panel> </Popover.Panel>
</Transition> </Transition>

View File

@ -4,17 +4,15 @@ import { useTheme } from "next-themes";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// hooks
import useUser from "hooks/use-user";
// ui // ui
import { PrimaryButton } from "components/ui"; import { PrimaryButton } from "components/ui";
import { ColorPickerInput } from "components/core"; import { ColorPickerInput } from "components/core";
// services
import userService from "services/user.service";
// helper
import { applyTheme } from "helpers/theme.helper";
// types // types
import { ICustomTheme } from "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 = { type Props = {
preLoadedData?: Partial<ICustomTheme> | null; preLoadedData?: Partial<ICustomTheme> | null;
@ -31,9 +29,11 @@ const defaultValues: ICustomTheme = {
theme: "custom", theme: "custom",
}; };
export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => { export const CustomThemeSelector: React.FC<Props> = observer(({ preLoadedData }) => {
const [darkPalette, setDarkPalette] = useState(false); const store: any = useMobxStore();
const { setTheme } = useTheme();
const [darkPalette, setDarkPalette] = useState(false);
const { const {
register, register,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
@ -44,11 +44,14 @@ export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
} = useForm<ICustomTheme>({ } = useForm<ICustomTheme>({
defaultValues, defaultValues,
}); });
useEffect(() => {
reset({
...defaultValues,
...preLoadedData,
});
}, [preLoadedData, reset]);
const { setTheme } = useTheme(); const handleUpdateTheme = async (formData: any) => {
const { mutateUser } = useUser();
const handleFormSubmit = async (formData: ICustomTheme) => {
const payload: ICustomTheme = { const payload: ICustomTheme = {
background: formData.background, background: formData.background,
text: formData.text, text: formData.text,
@ -60,34 +63,14 @@ export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
theme: "custom", theme: "custom",
}; };
await userService setTheme("custom");
.updateUser({
theme: payload,
})
.then((res) => {
mutateUser((prevData) => {
if (!prevData) return prevData;
return { ...prevData, ...res }; return store.user
}, false); .updateCurrentUserSettings({ theme: payload })
.then((response: any) => response)
setTheme("custom"); .catch((error: any) => error);
applyTheme(payload.palette, darkPalette);
})
.catch((err) => console.log(err));
}; };
const handleUpdateTheme = async (formData: any) => {
await handleFormSubmit({ ...formData, darkPalette });
};
useEffect(() => {
reset({
...defaultValues,
...preLoadedData,
});
}, [preLoadedData, reset]);
return ( return (
<form onSubmit={handleSubmit(handleUpdateTheme)}> <form onSubmit={handleSubmit(handleUpdateTheme)}>
<div className="space-y-5"> <div className="space-y-5">
@ -100,6 +83,7 @@ export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
</h3> </h3>
<ColorPickerInput <ColorPickerInput
name="background" name="background"
position="right"
error={errors.background} error={errors.background}
watch={watch} watch={watch}
setValue={setValue} setValue={setValue}
@ -137,6 +121,7 @@ export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
</h3> </h3>
<ColorPickerInput <ColorPickerInput
name="sidebarBackground" name="sidebarBackground"
position="right"
error={errors.sidebarBackground} error={errors.sidebarBackground}
watch={watch} watch={watch}
setValue={setValue} setValue={setValue}
@ -166,4 +151,4 @@ export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
</div> </div>
</form> </form>
); );
}; });

View File

@ -1,9 +1,5 @@
import { useState, useEffect } from "react";
// next-themes // next-themes
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
// services
import userService from "services/user.service";
// hooks // hooks
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
// constants // constants
@ -13,6 +9,10 @@ import { CustomSelect } from "components/ui";
// types // types
import { ICustomTheme } from "types"; import { ICustomTheme } from "types";
import { unsetCustomCssVariables } from "helpers/theme.helper"; 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 = { type Props = {
setPreLoadedData: React.Dispatch<React.SetStateAction<ICustomTheme | null>>; setPreLoadedData: React.Dispatch<React.SetStateAction<ICustomTheme | null>>;
@ -20,63 +20,30 @@ type Props = {
setCustomThemeSelectorOptions: React.Dispatch<React.SetStateAction<boolean>>; setCustomThemeSelectorOptions: React.Dispatch<React.SetStateAction<boolean>>;
}; };
export const ThemeSwitch: React.FC<Props> = ({ export const ThemeSwitch: React.FC<Props> = observer(
setPreLoadedData, ({ setPreLoadedData, customThemeSelectorOptions, setCustomThemeSelectorOptions }) => {
customThemeSelectorOptions, const store: any = useMobxStore();
setCustomThemeSelectorOptions,
}) => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme(); const { user, mutateUser } = useUser();
const { theme, setTheme } = useTheme();
const { user, mutateUser } = useUser(); 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 updateUserTheme = (newTheme: string) => { const currentThemeObj = THEMES_OBJ.find((t) => t.value === theme);
if (!user) return;
setTheme(newTheme); return (
<CustomSelect
mutateUser((prevData) => { value={theme}
if (!prevData) return prevData; label={
currentThemeObj ? (
return { <div className="flex items-center gap-2">
...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;
const currentThemeObj = THEMES_OBJ.find((t) => t.value === theme);
return (
<CustomSelect
value={theme}
label={
currentThemeObj ? (
<div className="flex items-center gap-2">
<div
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
style={{
borderColor: currentThemeObj.icon.border,
}}
>
<div <div
className="h-full w-1/2 rounded-l-full" className="h-full w-1/2 rounded-l-full"
style={{ style={{
@ -91,53 +58,45 @@ export const ThemeSwitch: React.FC<Props> = ({
}} }}
/> />
</div> </div>
{currentThemeObj.label} ) : (
</div> "Select your theme"
) : ( )
"Select your theme" }
) onChange={({ value, type }: { value: string; type: string }) => {
} if (value === "custom") {
onChange={({ value, type }: { value: string; type: string }) => { if (user?.theme.palette) {
if (value === "custom") { setPreLoadedData({
if (user?.theme.palette) { background: user.theme.background !== "" ? user.theme.background : "#0d101b",
setPreLoadedData({ text: user.theme.text !== "" ? user.theme.text : "#c5c5c5",
background: user.theme.background !== "" ? user.theme.background : "#0d101b", primary: user.theme.primary !== "" ? user.theme.primary : "#3f76ff",
text: user.theme.text !== "" ? user.theme.text : "#c5c5c5", sidebarBackground:
primary: user.theme.primary !== "" ? user.theme.primary : "#3f76ff", user.theme.sidebarBackground !== "" ? user.theme.sidebarBackground : "#0d101b",
sidebarBackground: sidebarText: user.theme.sidebarText !== "" ? user.theme.sidebarText : "#c5c5c5",
user.theme.sidebarBackground !== "" ? user.theme.sidebarBackground : "#0d101b", darkPalette: false,
sidebarText: user.theme.sidebarText !== "" ? user.theme.sidebarText : "#c5c5c5", palette:
darkPalette: false, user.theme.palette !== ",,,,"
palette: ? user.theme.palette
user.theme.palette !== ",,,," : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
? user.theme.palette theme: "custom",
: "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", });
theme: "custom", }
});
if (!customThemeSelectorOptions) setCustomThemeSelectorOptions(true);
} else {
if (customThemeSelectorOptions) setCustomThemeSelectorOptions(false);
unsetCustomCssVariables();
} }
if (!customThemeSelectorOptions) setCustomThemeSelectorOptions(true); updateUserTheme(value);
} else { document.documentElement.style.setProperty("--color-scheme", type);
if (customThemeSelectorOptions) setCustomThemeSelectorOptions(false); }}
unsetCustomCssVariables(); input
} width="w-full"
position="right"
updateUserTheme(value); >
document.documentElement.style.setProperty("--color-scheme", type); {THEMES_OBJ.map(({ value, label, type, icon }) => (
}} <CustomSelect.Option key={value} value={{ value, type }}>
input <div className="flex items-center gap-2">
width="w-full"
position="right"
>
{THEMES_OBJ.map(({ value, label, type, icon }) => (
<CustomSelect.Option key={value} value={{ value, type }}>
<div className="flex items-center gap-2">
<div
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
style={{
borderColor: icon.border,
}}
>
<div <div
className="h-full w-1/2 rounded-l-full" className="h-full w-1/2 rounded-l-full"
style={{ style={{
@ -152,10 +111,9 @@ export const ThemeSwitch: React.FC<Props> = ({
}} }}
/> />
</div> </div>
{label} </CustomSelect.Option>
</div> ))}
</CustomSelect.Option> </CustomSelect>
))} );
</CustomSelect> }
); );
};

View File

@ -10,7 +10,7 @@ import projectService from "services/project.service";
// hooks // hooks
import useProjects from "hooks/use-projects"; import useProjects from "hooks/use-projects";
// component // component
import { Avatar } from "components/ui"; import { Avatar, Icon } from "components/ui";
// icons // icons
import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicons/react/24/outline"; import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon, getStateGroupIcon } from "components/icons"; import { getPriorityIcon, getStateGroupIcon } from "components/icons";
@ -81,10 +81,7 @@ export const BoardHeader: React.FC<Props> = ({
break; break;
case "created_by": case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member; const member = members?.find((member) => member.member.id === groupTitle)?.member;
title = title = member?.display_name ?? "";
member?.first_name && member.first_name !== ""
? `${member.first_name} ${member.last_name}`
: member?.email ?? "";
break; break;
} }
@ -143,24 +140,22 @@ export const BoardHeader: React.FC<Props> = ({
> >
<div className={`flex items-center ${isCollapsed ? "gap-1" : "flex-col gap-2"}`}> <div className={`flex items-center ${isCollapsed ? "gap-1" : "flex-col gap-2"}`}>
<div <div
className={`flex cursor-pointer items-center gap-x-3 max-w-[316px] ${ className={`flex cursor-pointer items-center gap-x-2 max-w-[316px] ${
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : "" !isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
}`} }`}
> >
<span className="flex items-center">{getGroupIcon()}</span> <span className="flex items-center">{getGroupIcon()}</span>
<h2 <h2
className="text-lg font-semibold capitalize truncate" className={`text-lg font-semibold truncate ${
selectedGroup === "created_by" ? "" : "capitalize"
}`}
style={{ style={{
writingMode: isCollapsed ? "horizontal-tb" : "vertical-rl", writingMode: isCollapsed ? "horizontal-tb" : "vertical-rl",
}} }}
> >
{getGroupTitle()} {getGroupTitle()}
</h2> </h2>
<span <span className={`${isCollapsed ? "ml-0.5" : ""} py-1 text-center text-sm`}>
className={`${
isCollapsed ? "ml-0.5" : ""
} min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs`}
>
{groupedIssues?.[groupTitle].length ?? 0} {groupedIssues?.[groupTitle].length ?? 0}
</span> </span>
</div> </div>
@ -175,9 +170,12 @@ export const BoardHeader: React.FC<Props> = ({
}} }}
> >
{isCollapsed ? ( {isCollapsed ? (
<ArrowsPointingInIcon className="h-4 w-4" /> <Icon
iconName="close_fullscreen"
className="text-base font-medium text-custom-text-900"
/>
) : ( ) : (
<ArrowsPointingOutIcon className="h-4 w-4" /> <Icon iconName="open_in_full" className="text-base font-medium text-custom-text-900" />
)} )}
</button> </button>
{!disableUserActions && selectedGroup !== "created_by" && ( {!disableUserActions && selectedGroup !== "created_by" && (

View File

@ -24,6 +24,7 @@ import {
ViewEstimateSelect, ViewEstimateSelect,
ViewIssueLabel, ViewIssueLabel,
ViewPrioritySelect, ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect, ViewStateSelect,
} from "components/issues"; } from "components/issues";
// ui // ui
@ -124,7 +125,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
); );
} else { } else {
mutateIssues( mutateIssues(
(prevData) => (prevData: any) =>
handleIssuesMutation( handleIssuesMutation(
formData, formData,
groupTitle ?? "", groupTitle ?? "",
@ -231,7 +232,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
</a> </a>
</ContextMenu> </ContextMenu>
<div <div
className={`mb-3 rounded bg-custom-background-90 shadow ${ className={`mb-3 rounded bg-custom-background-100 shadow ${
snapshot.isDragging ? "border-2 border-custom-primary shadow-lg" : "" snapshot.isDragging ? "border-2 border-custom-primary shadow-lg" : ""
}`} }`}
ref={provided.innerRef} ref={provided.innerRef}
@ -300,10 +301,10 @@ export const SingleBoardIssue: React.FC<Props> = ({
{issue.project_detail.identifier}-{issue.sequence_id} {issue.project_detail.identifier}-{issue.sequence_id}
</div> </div>
)} )}
<h5 className="text-sm break-words line-clamp-3">{issue.name}</h5> <h5 className="text-sm break-words line-clamp-2">{issue.name}</h5>
</a> </a>
</Link> </Link>
<div className="relative mt-2.5 flex flex-wrap items-center gap-2 text-xs"> <div className="mt-2.5 flex overflow-x-scroll items-center gap-2 text-xs">
{properties.priority && ( {properties.priority && (
<ViewPrioritySelect <ViewPrioritySelect
issue={issue} issue={issue}
@ -322,6 +323,14 @@ export const SingleBoardIssue: React.FC<Props> = ({
selfPositioned selfPositioned
/> />
)} )}
{properties.start_date && issue.start_date && (
<ViewStartDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && issue.target_date && ( {properties.due_date && issue.target_date && (
<ViewDueDateSelect <ViewDueDateSelect
issue={issue} issue={issue}
@ -338,6 +347,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
issue={issue} issue={issue}
partialUpdateIssue={partialUpdateIssue} partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
customButton
user={user} user={user}
selfPositioned selfPositioned
/> />

View File

@ -21,6 +21,7 @@ import {
ViewEstimateSelect, ViewEstimateSelect,
ViewLabelSelect, ViewLabelSelect,
ViewPrioritySelect, ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect, ViewStateSelect,
} from "components/issues"; } from "components/issues";
// icons // icons
@ -230,7 +231,14 @@ export const SingleCalendarIssue: React.FC<Props> = ({
user={user} user={user}
/> />
)} )}
{properties.start_date && issue.start_date && (
<ViewStartDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && issue.target_date && ( {properties.due_date && issue.target_date && (
<ViewDueDateSelect <ViewDueDateSelect
issue={issue} issue={issue}

View File

@ -16,6 +16,7 @@ import {
ViewEstimateSelect, ViewEstimateSelect,
ViewIssueLabel, ViewIssueLabel,
ViewPrioritySelect, ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect, ViewStateSelect,
} from "components/issues"; } from "components/issues";
// ui // ui
@ -107,7 +108,7 @@ export const SingleListIssue: React.FC<Props> = ({
); );
} else { } else {
mutateIssues( mutateIssues(
(prevData) => (prevData: any) =>
handleIssuesMutation( handleIssuesMutation(
formData, formData,
groupTitle ?? "", groupTitle ?? "",
@ -244,6 +245,14 @@ export const SingleListIssue: React.FC<Props> = ({
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
/> />
)} )}
{properties.start_date && issue.start_date && (
<ViewStartDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
user={user}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && issue.target_date && ( {properties.due_date && issue.target_date && (
<ViewDueDateSelect <ViewDueDateSelect
issue={issue} issue={issue}

View File

@ -96,10 +96,7 @@ export const SingleList: React.FC<Props> = ({
break; break;
case "created_by": case "created_by":
const member = members?.find((member) => member.member.id === groupTitle)?.member; const member = members?.find((member) => member.member.id === groupTitle)?.member;
title = title = member?.display_name ?? "";
member?.first_name && member.first_name !== ""
? `${member.first_name} ${member.last_name}`
: member?.email ?? "";
break; break;
} }
@ -163,7 +160,11 @@ export const SingleList: React.FC<Props> = ({
<div className="flex items-center">{getGroupIcon()}</div> <div className="flex items-center">{getGroupIcon()}</div>
)} )}
{selectedGroup !== null ? ( {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()} {getGroupTitle()}
</h2> </h2>
) : ( ) : (

View File

@ -12,6 +12,7 @@ import {
ViewEstimateSelect, ViewEstimateSelect,
ViewIssueLabel, ViewIssueLabel,
ViewPrioritySelect, ViewPrioritySelect,
ViewStartDateSelect,
ViewStateSelect, ViewStateSelect,
} from "components/issues"; } from "components/issues";
import { Popover2 } from "@blueprintjs/popover2"; import { Popover2 } from "@blueprintjs/popover2";
@ -315,6 +316,19 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
</div> </div>
)} )}
{properties.start_date && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewStartDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
tooltipPosition={tooltipPosition}
noBorder
user={user}
isNotAllowed={isNotAllowed}
/>
</div>
)}
{properties.due_date && ( {properties.due_date && (
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200"> <div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
<ViewDueDateSelect <ViewDueDateSelect

View File

@ -361,14 +361,14 @@ export const ActiveCycleDetails: React.FC = () => {
height={16} height={16}
width={16} width={16}
className="rounded-full" 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"> <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>
)} )}
<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>
{cycle.assignees.length > 0 && ( {cycle.assignees.length > 0 && (

View File

@ -88,9 +88,10 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
avatar: assignee.avatar ?? "", avatar: assignee.avatar ?? "",
first_name: assignee.first_name ?? "", first_name: assignee.first_name ?? "",
last_name: assignee.last_name ?? "", last_name: assignee.last_name ?? "",
display_name: assignee.display_name ?? "",
}} }}
/> />
<span>{assignee.first_name}</span> <span>{assignee.display_name}</span>
</div> </div>
} }
completed={assignee.completed_issues} completed={assignee.completed_issues}

View File

@ -1,21 +1,28 @@
import { FC } from "react"; import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { KeyedMutator } from "swr";
// services
import cyclesService from "services/cycles.service";
// hooks
import useUser from "hooks/use-user";
// components // components
import { GanttChartRoot } from "components/gantt-chart"; import { CycleGanttBlock, GanttChartRoot, IBlockUpdateData } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// types // types
import { ICycle } from "types"; import { ICycle } from "types";
type Props = { type Props = {
cycles: ICycle[]; cycles: ICycle[];
mutateCycles: KeyedMutator<ICycle[]>;
}; };
export const CyclesListGanttChartView: FC<Props> = ({ cycles }) => { export const CyclesListGanttChartView: FC<Props> = ({ cycles, mutateCycles }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug } = router.query;
const { user } = useUser();
// rendering issues on gantt sidebar // rendering issues on gantt sidebar
const GanttSidebarBlockView = ({ data }: any) => ( const GanttSidebarBlockView = ({ data }: any) => (
@ -28,53 +35,63 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles }) => {
</div> </div>
); );
// rendering issues on gantt card const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => {
const GanttBlockView = ({ data }: { data: ICycle }) => ( if (!workspaceSlug || !user) return;
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: "rgb(var(--color-primary-100))" }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
</a>
</Link>
);
// handle gantt issue start date and target date mutateCycles((prevData: any) => {
const handleUpdateDates = async (data: any) => { if (!prevData) return prevData;
const payload = {
id: data?.id, const newList = prevData.map((p: any) => ({
start_date: data?.start_date, ...p,
target_date: data?.target_date, ...(p.id === cycle.id
}; ? {
start_date: payload.start_date ? payload.start_date : p.start_date,
target_date: payload.target_date ? payload.target_date : p.end_date,
sort_order: payload.sort_order ? payload.sort_order.newSortOrder : p.sort_order,
}
: {}),
}));
if (payload.sort_order) {
const removedElement = newList.splice(payload.sort_order.sourceIndex, 1)[0];
newList.splice(payload.sort_order.destinationIndex, 0, removedElement);
}
return newList;
}, false);
const newPayload: any = { ...payload };
if (newPayload.sort_order && payload.sort_order)
newPayload.sort_order = payload.sort_order.newSortOrder;
cyclesService.patchCycle(workspaceSlug.toString(), cycle.project, cycle.id, newPayload, user);
}; };
const blockFormat = (blocks: any) => const blockFormat = (blocks: ICycle[]) =>
blocks && blocks.length > 0 blocks && blocks.length > 0
? blocks.map((_block: any) => { ? blocks
if (_block?.start_date && _block.target_date) console.log("_block", _block); .filter((b) => b.start_date && b.end_date)
return { .map((block) => ({
start_date: new Date(_block.created_at), data: block,
target_date: new Date(_block.updated_at), id: block.id,
data: _block, sort_order: block.sort_order,
}; start_date: new Date(block.start_date ?? ""),
}) target_date: new Date(block.end_date ?? ""),
}))
: []; : [];
return ( return (
<div className="w-full h-full overflow-y-auto"> <div className="w-full h-full overflow-y-auto">
<GanttChartRoot <GanttChartRoot
title={"Cycles"} title="Cycles"
loaderTitle="Cycles" loaderTitle="Cycles"
blocks={cycles ? blockFormat(cycles) : null} blocks={cycles ? blockFormat(cycles) : null}
blockUpdateHandler={handleUpdateDates} blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />} sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />} blockRender={(data: any) => <CycleGanttBlock cycle={data as ICycle} />}
enableLeftDrag={false}
enableRightDrag={false}
/> />
</div> </div>
); );

View File

@ -17,7 +17,7 @@ export const AllCyclesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { data: allCyclesList } = useSWR( const { data: allCyclesList, mutate } = useSWR(
workspaceSlug && projectId ? CYCLES_LIST(projectId.toString()) : null, workspaceSlug && projectId ? CYCLES_LIST(projectId.toString()) : null,
workspaceSlug && projectId workspaceSlug && projectId
? () => ? () =>
@ -25,5 +25,5 @@ export const AllCyclesList: React.FC<Props> = ({ viewType }) => {
: null : null
); );
return <CyclesView cycles={allCyclesList} viewType={viewType} />; return <CyclesView cycles={allCyclesList} mutateCycles={mutate} viewType={viewType} />;
}; };

View File

@ -17,7 +17,7 @@ export const CompletedCyclesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { data: completedCyclesList } = useSWR( const { data: completedCyclesList, mutate } = useSWR(
workspaceSlug && projectId ? COMPLETED_CYCLES_LIST(projectId.toString()) : null, workspaceSlug && projectId ? COMPLETED_CYCLES_LIST(projectId.toString()) : null,
workspaceSlug && projectId workspaceSlug && projectId
? () => ? () =>
@ -29,5 +29,5 @@ export const CompletedCyclesList: React.FC<Props> = ({ viewType }) => {
: null : null
); );
return <CyclesView cycles={completedCyclesList} viewType={viewType} />; return <CyclesView cycles={completedCyclesList} mutateCycles={mutate} viewType={viewType} />;
}; };

View File

@ -17,7 +17,7 @@ export const DraftCyclesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { data: draftCyclesList } = useSWR( const { data: draftCyclesList, mutate } = useSWR(
workspaceSlug && projectId ? DRAFT_CYCLES_LIST(projectId.toString()) : null, workspaceSlug && projectId ? DRAFT_CYCLES_LIST(projectId.toString()) : null,
workspaceSlug && projectId workspaceSlug && projectId
? () => ? () =>
@ -25,5 +25,5 @@ export const DraftCyclesList: React.FC<Props> = ({ viewType }) => {
: null : null
); );
return <CyclesView cycles={draftCyclesList} viewType={viewType} />; return <CyclesView cycles={draftCyclesList} mutateCycles={mutate} viewType={viewType} />;
}; };

View File

@ -17,7 +17,7 @@ export const UpcomingCyclesList: React.FC<Props> = ({ viewType }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { data: upcomingCyclesList } = useSWR( const { data: upcomingCyclesList, mutate } = useSWR(
workspaceSlug && projectId ? UPCOMING_CYCLES_LIST(projectId.toString()) : null, workspaceSlug && projectId ? UPCOMING_CYCLES_LIST(projectId.toString()) : null,
workspaceSlug && projectId workspaceSlug && projectId
? () => ? () =>
@ -29,5 +29,5 @@ export const UpcomingCyclesList: React.FC<Props> = ({ viewType }) => {
: null : null
); );
return <CyclesView cycles={upcomingCyclesList} viewType={viewType} />; return <CyclesView cycles={upcomingCyclesList} mutateCycles={mutate} viewType={viewType} />;
}; };

View File

@ -2,7 +2,7 @@ import React, { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import { KeyedMutator, mutate } from "swr";
// services // services
import cyclesService from "services/cycles.service"; import cyclesService from "services/cycles.service";
@ -35,10 +35,11 @@ import {
type Props = { type Props = {
cycles: ICycle[] | undefined; cycles: ICycle[] | undefined;
mutateCycles: KeyedMutator<ICycle[]>;
viewType: string | null; viewType: string | null;
}; };
export const CyclesView: React.FC<Props> = ({ cycles, viewType }) => { export const CyclesView: React.FC<Props> = ({ cycles, mutateCycles, viewType }) => {
const [createUpdateCycleModal, setCreateUpdateCycleModal] = useState(false); const [createUpdateCycleModal, setCreateUpdateCycleModal] = useState(false);
const [selectedCycleToUpdate, setSelectedCycleToUpdate] = useState<ICycle | null>(null); const [selectedCycleToUpdate, setSelectedCycleToUpdate] = useState<ICycle | null>(null);
@ -202,7 +203,7 @@ export const CyclesView: React.FC<Props> = ({ cycles, viewType }) => {
))} ))}
</div> </div>
) : ( ) : (
<CyclesListGanttChartView cycles={cycles ?? []} /> <CyclesListGanttChartView cycles={cycles ?? []} mutateCycles={mutateCycles} />
) )
) : ( ) : (
<div className="h-full grid place-items-center text-center"> <div className="h-full grid place-items-center text-center">

View File

@ -1,20 +1,27 @@
import { FC } from "react";
// next imports
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// hooks // hooks
import useIssuesView from "hooks/use-issues-view";
import useUser from "hooks/use-user";
import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view"; import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view";
import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
// components
import {
GanttChartRoot,
IssueGanttBlock,
renderIssueBlocksStructure,
} from "components/gantt-chart";
// types
import { IIssue } from "types";
type Props = {}; export const CycleIssuesGanttChartView = () => {
export const CycleIssuesGanttChartView: FC<Props> = ({}) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query; const { workspaceSlug, projectId, cycleId } = router.query;
const { orderBy } = useIssuesView();
const { user } = useUser();
const { ganttIssues, mutateGanttIssues } = useGanttChartCycleIssues( const { ganttIssues, mutateGanttIssues } = useGanttChartCycleIssues(
workspaceSlug as string, workspaceSlug as string,
projectId as string, projectId as string,
@ -32,77 +39,18 @@ export const CycleIssuesGanttChartView: FC<Props> = ({}) => {
</div> </div>
); );
// rendering issues on gantt card
const GanttBlockView = ({ data }: any) => (
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${data?.id}`}>
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
<div
className="flex-shrink-0 w-[4px] h-full"
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
/>
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
{data?.name}
</div>
</Tooltip>
{data.infoToggle && (
<Tooltip
tooltipContent={`No due-date set, rendered according to last updated date.`}
className={`z-[999999]`}
>
<div className="flex-shrink-0 mx-2 w-[18px] h-[18px] overflow-hidden flex justify-center items-center">
<span className="material-symbols-rounded text-custom-text-200 text-[18px]">
info
</span>
</div>
</Tooltip>
)}
</a>
</Link>
);
// handle gantt issue start date and target date
const handleUpdateDates = async (data: any) => {
const payload = {
id: data?.id,
start_date: data?.start_date,
target_date: data?.target_date,
};
console.log("payload", payload);
};
const blockFormat = (blocks: any) =>
blocks && blocks.length > 0
? blocks.map((_block: any) => {
let startDate = new Date(_block.created_at);
let targetDate = new Date(_block.updated_at);
let infoToggle = true;
if (_block?.start_date && _block.target_date) {
startDate = _block?.start_date;
targetDate = _block.target_date;
infoToggle = false;
}
return {
start_date: new Date(startDate),
target_date: new Date(targetDate),
infoToggle: infoToggle,
data: _block,
};
})
: [];
return ( return (
<div className="w-full h-full p-3"> <div className="w-full h-full p-3">
<GanttChartRoot <GanttChartRoot
title="Cycles" title="Cycles"
loaderTitle="Cycles" loaderTitle="Cycles"
blocks={ganttIssues ? blockFormat(ganttIssues) : null} blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
blockUpdateHandler={handleUpdateDates} blockUpdateHandler={(block, payload) =>
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />} sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />} blockRender={(data: any) => <IssueGanttBlock issue={data as IIssue} />}
enableReorder={orderBy === "sort_order"}
/> />
</div> </div>
); );

View File

@ -450,14 +450,14 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
height={12} height={12}
width={12} width={12}
className="rounded-full" 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"> <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>
)} )}
<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> </div>

View File

@ -250,14 +250,14 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
height={16} height={16}
width={16} width={16}
className="rounded-full" 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"> <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>
)} )}
<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> </div>
<div className="flex h-5 items-center gap-2"> <div className="flex h-5 items-center gap-2">

View File

@ -254,11 +254,11 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
height={16} height={16}
width={16} width={16}
className="rounded-full" 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"> <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>
)} )}
</div> </div>

View File

@ -47,7 +47,7 @@ export const SingleEstimate: React.FC<Props> = ({
estimate: estimate.id, estimate: estimate.id,
}; };
mutateProjectDetails((prevData) => { mutateProjectDetails((prevData: any) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return { ...prevData, estimate: estimate.id }; return { ...prevData, estimate: estimate.id };

View File

@ -0,0 +1,185 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import CSVIntegrationService from "services/integration/csv.services";
// hooks
import useToast from "hooks/use-toast";
// ui
import { SecondaryButton, PrimaryButton, CustomSearchSelect } from "components/ui";
// types
import { ICurrentUserResponse, IImporterService } from "types";
// fetch-keys
import useProjects from "hooks/use-projects";
type Props = {
isOpen: boolean;
handleClose: () => void;
data: IImporterService | null;
user: ICurrentUserResponse | undefined;
provider: string | string[];
mutateServices: () => void;
};
export const Exporter: React.FC<Props> = ({
isOpen,
handleClose,
user,
provider,
mutateServices,
}) => {
const [exportLoading, setExportLoading] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { projects } = useProjects();
const { setToastAlert } = useToast();
const options = projects?.map((project) => ({
value: project.id,
query: project.name + project.identifier,
content: (
<div className="flex items-center gap-2">
<span className="text-custom-text-200 text-[0.65rem]">{project.identifier}</span>
{project.name}
</div>
),
}));
const [value, setValue] = React.useState<string[]>([]);
const [multiple, setMultiple] = React.useState<boolean>(false);
const onChange = (val: any) => {
setValue(val);
};
const ExportCSVToMail = async () => {
setExportLoading(true);
if (workspaceSlug && user && typeof provider === "string") {
const payload = {
provider: provider,
project: value,
multiple: multiple,
};
await CSVIntegrationService.exportCSVService(workspaceSlug as string, payload, user)
.then(() => {
mutateServices();
router.push(`/${workspaceSlug}/settings/exports`);
setExportLoading(false);
setToastAlert({
type: "success",
title: "Export Successful",
message: `You will be able to download the exported ${
provider === "csv"
? "CSV"
: provider === "xlsx"
? "Excel"
: provider === "json"
? "JSON"
: ""
} from the previous export.`,
});
})
.catch(() => {
setExportLoading(false);
setToastAlert({
type: "error",
title: "Error!",
message: "Export was unsuccessful. Please try again.",
});
});
}
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-6 gap-y-4 p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="flex items-center justify-start">
<h3 className="text-xl font-medium 2xl:text-2xl">
Export to{" "}
{provider === "csv"
? "CSV"
: provider === "xlsx"
? "Excel"
: provider === "json"
? "JSON"
: ""}
</h3>
</span>
</div>
<div>
<CustomSearchSelect
value={value ?? []}
onChange={(val: string[]) => onChange(val)}
options={options}
input={true}
label={
value && value.length > 0
? projects &&
projects
.filter((p) => value.includes(p.id))
.map((p) => p.identifier)
.join(", ")
: "All projects"
}
optionsClassName="min-w-full"
multiple
/>
</div>
<div
onClick={() => setMultiple(!multiple)}
className="flex items-center gap-2 max-w-min cursor-pointer"
>
<input
type="checkbox"
checked={multiple}
onChange={() => setMultiple(!multiple)}
/>
<div className="text-sm whitespace-nowrap">
Export the data into separate files
</div>
</div>
<div className="flex justify-end gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton
onClick={ExportCSVToMail}
disabled={exportLoading}
loading={exportLoading}
>
{exportLoading ? "Exporting..." : "Export"}
</PrimaryButton>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,171 @@
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// hooks
import useUserAuth from "hooks/use-user-auth";
// services
import IntegrationService from "services/integration";
// components
import { Exporter, SingleExport } from "components/exporter";
// ui
import { Icon, Loader, PrimaryButton } from "components/ui";
// icons
import { ArrowPathIcon } from "@heroicons/react/24/outline";
// fetch-keys
import { EXPORT_SERVICES_LIST } from "constants/fetch-keys";
// constants
import { EXPORTERS_LIST } from "constants/workspace";
const IntegrationGuide = () => {
const [refreshing, setRefreshing] = useState(false);
const per_page = 10;
const [cursor, setCursor] = useState<string | undefined>(`10:0:0`);
const router = useRouter();
const { workspaceSlug, provider } = router.query;
const { user } = useUserAuth();
const { data: exporterServices } = useSWR(
workspaceSlug && cursor
? EXPORT_SERVICES_LIST(workspaceSlug as string, cursor, `${per_page}`)
: null,
workspaceSlug && cursor
? () => IntegrationService.getExportsServicesList(workspaceSlug as string, cursor, per_page)
: null
);
const handleCsvClose = () => {
router.replace(`/plane/settings/exports`);
};
return (
<>
<div className="h-full space-y-2">
<>
<div className="space-y-2">
{EXPORTERS_LIST.map((service) => (
<div
key={service.provider}
className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4"
>
<div className="flex items-center gap-4 whitespace-nowrap">
<div className="relative h-10 w-10 flex-shrink-0">
<Image
src={service.logo}
layout="fill"
objectFit="cover"
alt={`${service.title} Logo`}
/>
</div>
<div className="w-full">
<h3>{service.title}</h3>
<p className="text-sm text-custom-text-200">{service.description}</p>
</div>
<div className="flex-shrink-0">
<Link href={`/${workspaceSlug}/settings/exports?provider=${service.provider}`}>
<a>
<PrimaryButton>
<span className="capitalize">{service.type}</span> now
</PrimaryButton>
</a>
</Link>
</div>
</div>
</div>
))}
</div>
<div className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4">
<h3 className="mb-2 flex gap-2 text-lg font-medium justify-between">
<div className="flex gap-2">
<div className="">Previous Exports</div>
<button
type="button"
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 py-1 px-1.5 text-xs outline-none"
onClick={() => {
setRefreshing(true);
mutate(
EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`)
).then(() => setRefreshing(false));
}}
>
<ArrowPathIcon className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "}
{refreshing ? "Refreshing..." : "Refresh status"}
</button>
</div>
<div className="flex gap-2 items-center text-xs">
<button
disabled={!exporterServices?.prev_page_results}
onClick={() =>
exporterServices?.prev_page_results && setCursor(exporterServices?.prev_cursor)
}
className={`flex items-center border border-custom-primary-100 text-custom-primary-100 px-1 rounded ${
exporterServices?.prev_page_results
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
: "cursor-not-allowed opacity-75"
}`}
>
<Icon iconName="keyboard_arrow_left" className="!text-lg" />
<div className="pr-1">Prev</div>
</button>
<button
disabled={!exporterServices?.next_page_results}
onClick={() =>
exporterServices?.next_page_results && setCursor(exporterServices?.next_cursor)
}
className={`flex items-center border border-custom-primary-100 text-custom-primary-100 px-1 rounded ${
exporterServices?.next_page_results
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
: "cursor-not-allowed opacity-75"
}`}
>
<div className="pl-1">Next</div>
<Icon iconName="keyboard_arrow_right" className="!text-lg" />
</button>
</div>
</h3>
{exporterServices && exporterServices?.results ? (
exporterServices?.results?.length > 0 ? (
<div className="space-y-2">
<div className="divide-y divide-custom-border-200">
{exporterServices?.results.map((service) => (
<SingleExport key={service.id} service={service} refreshing={refreshing} />
))}
</div>
</div>
) : (
<p className="py-2 text-sm text-custom-text-200">No previous export available.</p>
)
) : (
<Loader className="mt-6 grid grid-cols-1 gap-3">
<Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" />
<Loader.Item height="40px" width="100%" />
</Loader>
)}
</div>
</>
{provider && (
<Exporter
isOpen={true}
handleClose={() => handleCsvClose()}
data={null}
user={user}
provider={provider}
mutateServices={() =>
mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`))
}
/>
)}
</div>
</>
);
};
export default IntegrationGuide;

View File

@ -0,0 +1,4 @@
//layout
export * from "./single-export";
// csv
export * from "./export-modal";

View File

@ -0,0 +1,81 @@
import React from "react";
// next imports
import Link from "next/link";
// ui
import { PrimaryButton } from "components/ui"; // icons
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types
import { IExportData } from "types";
type Props = {
service: IExportData;
refreshing: boolean;
};
export const SingleExport: React.FC<Props> = ({ service, refreshing }) => {
const provider = service.provider;
const [isLoading, setIsLoading] = React.useState(false);
const checkExpiry = (inputDateString: string) => {
const currentDate = new Date();
const expiryDate = new Date(inputDateString);
expiryDate.setDate(expiryDate.getDate() + 7);
return expiryDate > currentDate;
};
return (
<div className="flex items-center justify-between gap-2 py-3">
<div>
<h4 className="flex items-center gap-2 text-sm">
<span>
Export to{" "}
<span className="font-medium">
{provider === "csv"
? "CSV"
: provider === "xlsx"
? "Excel"
: provider === "json"
? "JSON"
: ""}
</span>{" "}
</span>
<span
className={`rounded px-2 py-0.5 text-xs capitalize ${
service.status === "completed"
? "bg-green-500/20 text-green-500"
: service.status === "processing"
? "bg-yellow-500/20 text-yellow-500"
: service.status === "failed"
? "bg-red-500/20 text-red-500"
: service.status === "expired"
? "bg-orange-500/20 text-orange-500"
: ""
}`}
>
{refreshing ? "Refreshing..." : service.status}
</span>
</h4>
<div className="mt-2 flex items-center gap-2 text-xs text-custom-text-200">
<span>{renderShortDateWithYearFormat(service.created_at)}</span>|
<span>Exported by {service?.initiated_by_detail?.display_name}</span>
</div>
</div>
{checkExpiry(service.created_at) ? (
<>
{service.status == "completed" && (
<div>
<Link href={service?.url}>
<PrimaryButton className="w-full text-center">
{isLoading ? "Downloading..." : "Download"}
</PrimaryButton>
</Link>
</div>
)}
</>
) : (
<div className="text-xs text-red-500">Expired</div>
)}
</div>
);
};

View File

@ -0,0 +1,103 @@
import Link from "next/link";
import { useRouter } from "next/router";
// ui
import { Tooltip } from "components/ui";
// helpers
import { renderShortDate } from "helpers/date-time.helper";
// types
import { ICycle, IIssue, IModule } from "types";
// constants
import { MODULE_STATUS } from "constants/module";
export const IssueGanttBlock = ({ issue }: { issue: IIssue }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
<div
className="flex-shrink-0 w-0.5 h-full"
style={{ backgroundColor: issue.state_detail?.color }}
/>
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{issue.name}</h5>
<div>
{renderShortDate(issue.start_date ?? "")} to{" "}
{renderShortDate(issue.target_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{issue.name}
</div>
</Tooltip>
</a>
</Link>
);
};
export const CycleGanttBlock = ({ cycle }: { cycle: ICycle }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Link href={`/${workspaceSlug}/projects/${cycle.project}/cycles/${cycle.id}`}>
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
<div className="flex-shrink-0 w-0.5 h-full bg-custom-primary-100" />
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{cycle.name}</h5>
<div>
{renderShortDate(cycle.start_date ?? "")} to {renderShortDate(cycle.end_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{cycle.name}
</div>
</Tooltip>
</a>
</Link>
);
};
export const ModuleGanttBlock = ({ module }: { module: IModule }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
<div
className="flex-shrink-0 w-0.5 h-full"
style={{ backgroundColor: MODULE_STATUS.find((s) => s.value === module.status)?.color }}
/>
<Tooltip
tooltipContent={
<div className="space-y-1">
<h5>{module.name}</h5>
<div>
{renderShortDate(module.start_date ?? "")} to{" "}
{renderShortDate(module.target_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
{module.name}
</div>
</Tooltip>
</a>
</Link>
);
};

View File

@ -0,0 +1,178 @@
import { FC } from "react";
// react-beautiful-dnd
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// helpers
import { ChartDraggable } from "../helpers/draggable";
import { renderDateFormat } from "helpers/date-time.helper";
// types
import { IBlockUpdateData, IGanttBlock } from "../types";
export const GanttChartBlocks: FC<{
itemsContainerWidth: number;
blocks: IGanttBlock[] | null;
sidebarBlockRender: FC;
blockRender: FC;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
enableLeftDrag: boolean;
enableRightDrag: boolean;
enableReorder: boolean;
}> = ({
itemsContainerWidth,
blocks,
sidebarBlockRender,
blockRender,
blockUpdateHandler,
enableLeftDrag,
enableRightDrag,
enableReorder,
}) => {
const handleChartBlockPosition = (
block: IGanttBlock,
totalBlockShifts: number,
dragDirection: "left" | "right"
) => {
let updatedDate = new Date();
if (dragDirection === "left") {
const originalDate = new Date(block.start_date);
const currentDay = originalDate.getDate();
updatedDate = new Date(originalDate);
updatedDate.setDate(currentDay - totalBlockShifts);
} else {
const originalDate = new Date(block.target_date);
const currentDay = originalDate.getDate();
updatedDate = new Date(originalDate);
updatedDate.setDate(currentDay + totalBlockShifts);
}
blockUpdateHandler(block.data, {
[dragDirection === "left" ? "start_date" : "target_date"]: renderDateFormat(updatedDate),
});
};
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
const { source, destination, draggableId } = result;
if (!destination) return;
if (source.index === destination.index && document) {
// const draggedBlock = document.querySelector(`#${draggableId}`) as HTMLElement;
// const blockStyles = window.getComputedStyle(draggedBlock);
// console.log(blockStyles.marginLeft);
return;
}
let updatedSortOrder = blocks[source.index].sort_order;
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
else if (destination.index === blocks.length - 1)
updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
});
};
return (
<div
className="relative z-[5] mt-[72px] h-full overflow-hidden overflow-y-auto"
style={{ width: `${itemsContainerWidth}px` }}
>
<DragDropContext onDragEnd={handleOrderChange}>
<StrictModeDroppable droppableId="gantt">
{(droppableProvided, droppableSnapshot) => (
<div
className="w-full space-y-2"
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
>
<>
{blocks &&
blocks.length > 0 &&
blocks.map(
(block, index: number) =>
block.start_date &&
block.target_date && (
<Draggable
key={`block-${block.id}`}
draggableId={`block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided) => (
<div
className={
droppableSnapshot.isDraggingOver ? "bg-custom-border-100/10" : ""
}
ref={provided.innerRef}
{...provided.draggableProps}
>
<ChartDraggable
block={block}
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
enableLeftDrag={enableLeftDrag}
enableRightDrag={enableRightDrag}
provided={provided}
>
<div
className="rounded shadow-sm bg-custom-background-80 overflow-hidden h-9 flex items-center transition-all"
style={{
width: `${block.position?.width}px`,
}}
>
{blockRender({
...block.data,
})}
</div>
</ChartDraggable>
</div>
)}
</Draggable>
)
)}
{droppableProvided.placeholder}
</>
</div>
)}
</StrictModeDroppable>
</DragDropContext>
{/* sidebar */}
{/* <div className="fixed top-0 bottom-0 w-[300px] flex-shrink-0 divide-y divide-custom-border-200 border-r border-custom-border-200 overflow-y-auto">
{blocks &&
blocks.length > 0 &&
blocks.map((block: any, _idx: number) => (
<div className="relative h-[40px] bg-custom-background-100" key={`sidebar-blocks-${_idx}`}>
{sidebarBlockRender(block?.data)}
</div>
))}
</div> */}
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./block";
export * from "./blocks-display";

View File

@ -1,82 +0,0 @@
import { FC, useEffect, useState } from "react";
// helpers
import { ChartDraggable } from "../helpers/draggable";
// data
import { datePreview } from "../data";
export const GanttChartBlocks: FC<{
itemsContainerWidth: number;
blocks: null | any[];
sidebarBlockRender: FC;
blockRender: FC;
}> = ({ itemsContainerWidth, blocks, sidebarBlockRender, blockRender }) => {
const handleChartBlockPosition = (block: any) => {
// setChartBlocks((prevData: any) =>
// prevData.map((_block: any) => (_block?.data?.id == block?.data?.id ? block : _block))
// );
};
return (
<div
className="relative z-[5] mt-[58px] h-full w-[4000px] divide-x divide-gray-300 overflow-hidden overflow-y-auto"
style={{ width: `${itemsContainerWidth}px` }}
>
<div className="w-full">
{blocks &&
blocks.length > 0 &&
blocks.map((block: any, _idx: number) => (
<>
{block.start_date && block.target_date && (
<ChartDraggable
className="relative flex h-[40px] items-center"
key={`blocks-${_idx}`}
block={block}
handleBlock={handleChartBlockPosition}
>
<div
className="relative group inline-flex cursor-pointer items-center font-medium transition-all"
style={{ marginLeft: `${block?.position?.marginLeft}px` }}
>
<div className="flex-shrink-0 relative w-0 h-0 flex items-center invisible group-hover:visible whitespace-nowrap">
<div className="absolute right-0 mr-[5px] rounded-sm bg-custom-background-90 px-2 py-0.5 text-xs font-medium">
{block?.start_date ? datePreview(block?.start_date) : "-"}
</div>
</div>
<div
className="rounded shadow-sm bg-custom-background-100 overflow-hidden relative flex items-center h-[34px] border border-custom-border-200"
style={{
width: `${block?.position?.width}px`,
}}
>
{blockRender({
...block?.data,
infoToggle: block?.infoToggle ? true : false,
})}
</div>
<div className="flex-shrink-0 relative w-0 h-0 flex items-center invisible group-hover:visible whitespace-nowrap">
<div className="absolute left-0 ml-[5px] mr-[5px] rounded-sm bg-custom-background-90 px-2 py-0.5 text-xs font-medium">
{block?.target_date ? datePreview(block?.target_date) : "-"}
</div>
</div>
</div>
</ChartDraggable>
)}
</>
))}
</div>
{/* sidebar */}
{/* <div className="fixed top-0 bottom-0 w-[300px] flex-shrink-0 divide-y divide-custom-border-200 border-r border-custom-border-200 overflow-y-auto">
{blocks &&
blocks.length > 0 &&
blocks.map((block: any, _idx: number) => (
<div className="relative h-[40px] bg-custom-background-100" key={`sidebar-blocks-${_idx}`}>
{sidebarBlockRender(block?.data)}
</div>
))}
</div> */}
</div>
);
};

View File

@ -25,7 +25,7 @@ export const BiWeekChartView: FC<any> = () => {
<div <div
key={`sub-title-${_idxRoot}-${_idx}`} key={`sub-title-${_idxRoot}-${_idx}`}
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap" className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
style={{ width: `${currentViewData.data.width}px` }} style={{ width: `${currentViewData?.data.width}px` }}
> >
<div <div
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${ className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${

View File

@ -25,7 +25,7 @@ export const DayChartView: FC<any> = () => {
<div <div
key={`sub-title-${_idxRoot}-${_idx}`} key={`sub-title-${_idxRoot}-${_idx}`}
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap" className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
style={{ width: `${currentViewData.data.width}px` }} style={{ width: `${currentViewData?.data.width}px` }}
> >
<div <div
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${ className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${

View File

@ -25,7 +25,7 @@ export const HourChartView: FC<any> = () => {
<div <div
key={`sub-title-${_idxRoot}-${_idx}`} key={`sub-title-${_idxRoot}-${_idx}`}
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap" className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
style={{ width: `${currentViewData.data.width}px` }} style={{ width: `${currentViewData?.data.width}px` }}
> >
<div <div
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${ className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${

View File

@ -1,13 +1,8 @@
import { FC, useEffect, useState } from "react"; import { FC, useEffect, useState } from "react";
// icons // icons
import { import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid";
Bars4Icon,
XMarkIcon,
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
} from "@heroicons/react/20/solid";
// components // components
import { GanttChartBlocks } from "../blocks"; import { GanttChartBlocks } from "components/gantt-chart";
// import { HourChartView } from "./hours"; // import { HourChartView } from "./hours";
// import { DayChartView } from "./day"; // import { DayChartView } from "./day";
// import { WeekChartView } from "./week"; // import { WeekChartView } from "./week";
@ -30,9 +25,9 @@ import {
getMonthChartItemPositionWidthInMonth, getMonthChartItemPositionWidthInMonth,
} from "../views"; } from "../views";
// types // types
import { ChartDataType } from "../types"; import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types";
// data // data
import { datePreview, currentViewDataWithView } from "../data"; import { currentViewDataWithView } from "../data";
// context // context
import { useChart } from "../hooks"; import { useChart } from "../hooks";
@ -40,10 +35,13 @@ type ChartViewRootProps = {
border: boolean; border: boolean;
title: null | string; title: null | string;
loaderTitle: string; loaderTitle: string;
blocks: any; blocks: IGanttBlock[] | null;
blockUpdateHandler: (data: any) => void; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
sidebarBlockRender: FC<any>; sidebarBlockRender: FC<any>;
blockRender: FC<any>; blockRender: FC<any>;
enableLeftDrag: boolean;
enableRightDrag: boolean;
enableReorder: boolean;
}; };
export const ChartViewRoot: FC<ChartViewRootProps> = ({ export const ChartViewRoot: FC<ChartViewRootProps> = ({
@ -54,6 +52,9 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
blockUpdateHandler, blockUpdateHandler,
sidebarBlockRender, sidebarBlockRender,
blockRender, blockRender,
enableLeftDrag,
enableRightDrag,
enableReorder,
}) => { }) => {
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
@ -62,13 +63,13 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
const [blocksSidebarView, setBlocksSidebarView] = useState<boolean>(false); const [blocksSidebarView, setBlocksSidebarView] = useState<boolean>(false);
// blocks state management starts // blocks state management starts
const [chartBlocks, setChartBlocks] = useState<any[] | null>(null); const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null);
const renderBlockStructure = (view: any, blocks: any) => const renderBlockStructure = (view: any, blocks: IGanttBlock[]) =>
blocks && blocks.length > 0 blocks && blocks.length > 0
? blocks.map((_block: any) => ({ ? blocks.map((block: any) => ({
..._block, ...block,
position: getMonthChartItemPositionWidthInMonth(view, _block), position: getMonthChartItemPositionWidthInMonth(view, block),
})) }))
: []; : [];
@ -154,13 +155,14 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
const updatingCurrentLeftScrollPosition = (width: number) => { const updatingCurrentLeftScrollPosition = (width: number) => {
const scrollContainer = document.getElementById("scroll-container") as HTMLElement; const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
scrollContainer.scrollLeft = width + scrollContainer.scrollLeft; scrollContainer.scrollLeft = width + scrollContainer?.scrollLeft;
setItemsContainerWidth(width + scrollContainer.scrollLeft); setItemsContainerWidth(width + scrollContainer?.scrollLeft);
}; };
const handleScrollToCurrentSelectedDate = (currentState: ChartDataType, date: Date) => { const handleScrollToCurrentSelectedDate = (currentState: ChartDataType, date: Date) => {
const scrollContainer = document.getElementById("scroll-container") as HTMLElement; const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
const clientVisibleWidth: number = scrollContainer.clientWidth;
const clientVisibleWidth: number = scrollContainer?.clientWidth;
let scrollWidth: number = 0; let scrollWidth: number = 0;
let daysDifference: number = 0; let daysDifference: number = 0;
@ -189,9 +191,9 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
const onScroll = () => { const onScroll = () => {
const scrollContainer = document.getElementById("scroll-container") as HTMLElement; const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
const scrollWidth: number = scrollContainer.scrollWidth; const scrollWidth: number = scrollContainer?.scrollWidth;
const clientVisibleWidth: number = scrollContainer.clientWidth; const clientVisibleWidth: number = scrollContainer?.clientWidth;
const currentScrollPosition: number = scrollContainer.scrollLeft; const currentScrollPosition: number = scrollContainer?.scrollLeft;
const approxRangeLeft: number = const approxRangeLeft: number =
scrollWidth >= clientVisibleWidth + 1000 ? 1000 : scrollWidth - clientVisibleWidth; scrollWidth >= clientVisibleWidth + 1000 ? 1000 : scrollWidth - clientVisibleWidth;
@ -207,6 +209,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
const scrollContainer = document.getElementById("scroll-container") as HTMLElement; const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
scrollContainer.addEventListener("scroll", onScroll); scrollContainer.addEventListener("scroll", onScroll);
return () => { return () => {
scrollContainer.removeEventListener("scroll", onScroll); scrollContainer.removeEventListener("scroll", onScroll);
}; };
@ -242,7 +245,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
</div> */} </div> */}
{/* chart header */} {/* chart header */}
<div className="flex w-full flex-shrink-0 flex-wrap items-center gap-5 gap-y-3 whitespace-nowrap p-2"> <div className="flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap p-2">
{/* <div {/* <div
className="transition-all border border-custom-border-200 w-[30px] h-[30px] flex justify-center items-center cursor-pointer rounded-sm hover:bg-custom-background-80" className="transition-all border border-custom-border-200 w-[30px] h-[30px] flex justify-center items-center cursor-pointer rounded-sm hover:bg-custom-background-80"
onClick={() => setBlocksSidebarView(() => !blocksSidebarView)} onClick={() => setBlocksSidebarView(() => !blocksSidebarView)}
@ -301,8 +304,8 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
</div> </div>
<div <div
className="transition-all border border-custom-border-200 w-[30px] h-[30px] flex justify-center items-center cursor-pointer rounded-sm hover:bg-custom-background-80" className="transition-all border border-custom-border-200 p-1 flex justify-center items-center cursor-pointer rounded-sm hover:bg-custom-background-80"
onClick={() => setFullScreenMode(() => !fullScreenMode)} onClick={() => setFullScreenMode((prevData) => !prevData)}
> >
{fullScreenMode ? ( {fullScreenMode ? (
<ArrowsPointingInIcon className="h-4 w-4" /> <ArrowsPointingInIcon className="h-4 w-4" />
@ -325,6 +328,10 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
blocks={chartBlocks} blocks={chartBlocks}
sidebarBlockRender={sidebarBlockRender} sidebarBlockRender={sidebarBlockRender}
blockRender={blockRender} blockRender={blockRender}
blockUpdateHandler={blockUpdateHandler}
enableLeftDrag={enableLeftDrag}
enableRightDrag={enableRightDrag}
enableReorder={enableReorder}
/> />
)} )}

View File

@ -1,48 +1,55 @@
import { FC } from "react"; import { FC } from "react";
// context
// hooks
import { useChart } from "../hooks"; import { useChart } from "../hooks";
// types
import { IMonthBlock } from "../views";
export const MonthChartView: FC<any> = () => { export const MonthChartView: FC<any> = () => {
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); const { currentViewData, renderView } = useChart();
const monthBlocks: IMonthBlock[] = renderView;
return ( return (
<> <>
<div className="absolute flex h-full flex-grow divide-x divide-custom-border-200"> <div className="absolute flex h-full flex-grow divide-x divide-custom-border-100/50">
{renderView && {monthBlocks &&
renderView.length > 0 && monthBlocks.length > 0 &&
renderView.map((_itemRoot: any, _idxRoot: any) => ( monthBlocks.map((block, _idxRoot) => (
<div key={`title-${_idxRoot}`} className="relative flex flex-col"> <div key={`month-${block?.month}-${block?.year}`} className="relative flex flex-col">
<div className="relative border-b border-custom-border-200"> <div className="relative border-b border-custom-border-200">
<div className="sticky left-0 inline-flex whitespace-nowrap px-2 py-1 text-sm font-medium capitalize"> <div className="sticky left-0 inline-flex whitespace-nowrap px-2 py-1 text-sm font-medium capitalize">
{_itemRoot?.title} {block?.title}
</div> </div>
</div> </div>
<div className="flex h-full w-full divide-x divide-custom-border-200"> <div className="flex h-full w-full divide-x divide-custom-border-100/50">
{_itemRoot.children && {block?.children &&
_itemRoot.children.length > 0 && block?.children.length > 0 &&
_itemRoot.children.map((_item: any, _idx: any) => ( block?.children.map((monthDay, _idx) => (
<div <div
key={`sub-title-${_idxRoot}-${_idx}`} key={`sub-title-${_idxRoot}-${_idx}`}
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap" className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
style={{ width: `${currentViewData.data.width}px` }} style={{ width: `${currentViewData?.data.width}px` }}
> >
<div <div
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${ className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
_item?.today ? `text-red-500 border-red-500` : `border-custom-border-200` monthDay?.today
? `text-red-500 border-red-500`
: `border-custom-border-200`
}`} }`}
> >
<div>{_item.title}</div> <div>{monthDay?.title}</div>
</div> </div>
<div <div
className={`relative h-full w-full flex-1 flex justify-center ${ className={`relative h-full w-full flex-1 flex justify-center ${
["sat", "sun"].includes(_item?.dayData?.shortTitle || "") ["sat", "sun"].includes(monthDay?.dayData?.shortTitle || "")
? `bg-custom-background-90` ? `bg-custom-background-90`
: `` : ``
}`} }`}
> >
{_item?.today && ( {monthDay?.today && (
<div className="absolute top-0 bottom-0 border border-red-500"> </div> <div className="absolute top-0 bottom-0 w-[1px] bg-red-500" />
)} )}
</div> </div>
</div> </div>

View File

@ -25,7 +25,7 @@ export const QuarterChartView: FC<any> = () => {
<div <div
key={`sub-title-${_idxRoot}-${_idx}`} key={`sub-title-${_idxRoot}-${_idx}`}
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap" className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
style={{ width: `${currentViewData.data.width}px` }} style={{ width: `${currentViewData?.data.width}px` }}
> >
<div <div
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${ className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${

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