Merge branch 'develop' of https://github.com/makeplane/plane into feat/csv_exporter

This commit is contained in:
srinivaspendem 2023-08-11 21:27:13 +05:30
commit 987806258a
181 changed files with 6816 additions and 1495 deletions

View File

@ -18,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
@ -41,6 +43,7 @@ from .issue import (
IssueSubscriberSerializer, IssueSubscriberSerializer,
IssueReactionSerializer, IssueReactionSerializer,
CommentReactionSerializer, CommentReactionSerializer,
IssueVoteSerializer,
) )
from .module import ( from .module import (
@ -78,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

@ -14,6 +14,7 @@ from plane.db.models import (
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,7 @@ 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)
class Meta: class Meta:
model = Project model = Project
@ -115,7 +124,6 @@ class ProjectMemberAdminSerializer(BaseSerializer):
project = ProjectLiteSerializer(read_only=True) project = ProjectLiteSerializer(read_only=True)
member = UserAdminLiteSerializer(read_only=True) member = UserAdminLiteSerializer(read_only=True)
class Meta: class Meta:
model = ProjectMember model = ProjectMember
fields = "__all__" fields = "__all__"
@ -148,8 +156,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)
@ -158,3 +164,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

@ -86,6 +86,7 @@ from plane.api.views import (
IssueAttachmentEndpoint, IssueAttachmentEndpoint,
IssueArchiveViewSet, IssueArchiveViewSet,
IssueSubscriberViewSet, IssueSubscriberViewSet,
IssueCommentPublicViewSet,
IssueReactionViewSet, IssueReactionViewSet,
CommentReactionViewSet, CommentReactionViewSet,
ExportIssuesEndpoint, ExportIssuesEndpoint,
@ -165,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
) )
@ -1481,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,6 +12,9 @@ from .project import (
ProjectUserViewsEndpoint, ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint, ProjectMemberUserEndpoint,
ProjectFavoritesViewSet, ProjectFavoritesViewSet,
ProjectDeployBoardIssuesPublicEndpoint,
ProjectDeployBoardViewSet,
ProjectDeployBoardPublicSettingsEndpoint,
ProjectMemberEndpoint, ProjectMemberEndpoint,
) )
from .user import ( from .user import (
@ -75,9 +78,12 @@ from .issue import (
IssueAttachmentEndpoint, IssueAttachmentEndpoint,
IssueArchiveViewSet, IssueArchiveViewSet,
IssueSubscriberViewSet, IssueSubscriberViewSet,
IssueCommentPublicViewSet,
CommentReactionViewSet, CommentReactionViewSet,
IssueReactionViewSet, IssueReactionViewSet,
ExportIssuesEndpoint IssueReactionPublicViewSet,
CommentReactionPublicViewSet,
IssueVotePublicViewSet,
) )
from .auth_extended import ( from .auth_extended import (
@ -145,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,
@ -155,4 +161,8 @@ from .analytic import (
DefaultAnalyticsEndpoint, DefaultAnalyticsEndpoint,
) )
from .notification import NotificationViewSet, UnreadNotificationEndpoint from .notification import NotificationViewSet, UnreadNotificationEndpoint
from .exporter import (
ExportIssuesEndpoint,
)

View File

@ -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__display_name") .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__display_name") .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__display_name") .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

@ -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,99 @@
# 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,
)
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

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

@ -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,11 +71,12 @@ 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
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.bgtasks.project_issue_export import issue_export_task
class IssueViewSet(BaseViewSet): class IssueViewSet(BaseViewSet):
@ -169,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]
@ -362,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)
) )
@ -744,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,
@ -1448,6 +1459,374 @@ class CommentReactionViewSet(BaseViewSet):
) )
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): class ExportIssuesEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [

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

@ -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, ProjectEntityPermission 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):
@ -109,6 +139,12 @@ 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")
)
.distinct() .distinct()
) )
@ -180,7 +216,7 @@ 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"],
@ -451,7 +487,7 @@ 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,
@ -986,6 +1022,63 @@ class ProjectFavoritesViewSet(BaseViewSet):
) )
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): class ProjectMemberEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
@ -1004,3 +1097,176 @@ class ProjectMemberEndpoint(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 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

@ -0,0 +1,357 @@
# Python imports
import csv
import io
import json
import boto3
import zipfile
from datetime import datetime, date, timedelta
# 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
from openpyxl.styles import NamedStyle
from openpyxl.utils.datetime import to_excel
# Module imports
from plane.db.models import Issue, ExporterHistory, Project
class DateTimeEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
return super().default(obj)
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, cls=DateTimeEncoder)
def create_xlsx_file(data):
workbook = Workbook()
sheet = workbook.active
no_timezone_style = NamedStyle(name="no_timezone_style")
no_timezone_style.number_format = "yyyy-mm-dd hh:mm:ss"
for row in data:
sheet.append(row)
for column_cells in sheet.columns:
for cell in column_cells:
if isinstance(cell.value, datetime):
cell.style = no_timezone_style
cell.value = to_excel(cell.value.replace(tzinfo=None))
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):
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}/issues-{datetime.now().date()}.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"],
issue["issue_cycle__cycle__start_date"],
issue["issue_cycle__cycle__end_date"],
issue["issue_module__module__name"],
issue["issue_module__module__start_date"],
issue["issue_module__module__target_date"],
issue["created_at"],
issue["updated_at"],
issue["completed_at"],
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": issue["issue_cycle__cycle__start_date"],
"Cycle End Date": issue["issue_cycle__cycle__end_date"],
"Module Name": issue["issue_module__module__name"],
"Module Start Date": issue["issue_module__module__start_date"],
"Module Target Date": issue["issue_module__module__target_date"],
"Created At": issue["created_at"],
"Updated At": issue["updated_at"],
"Completed At": issue["completed_at"],
"Archived At": 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):
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)
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

@ -1,191 +0,0 @@
# Python imports
import csv
import io
# Django imports
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
from django.utils import timezone
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Issue
@shared_task
def issue_export_task(email, data, slug, exporter_name):
try:
project_ids = data.get("project_id", [])
issues_filter = {"workspace__slug": slug}
if project_ids:
issues_filter["project_id__in"] = project_ids
issues = (
Issue.objects.filter(**issues_filter)
.select_related("project", "workspace", "state", "parent", "created_by")
.prefetch_related(
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
)
.values_list(
"project__identifier",
"sequence_id",
"name",
"description_stripped",
"priority",
"start_date",
"target_date",
"state__name",
"project__name",
"created_at",
"updated_at",
"completed_at",
"archived_at",
"issue_cycle__cycle__name",
"issue_cycle__cycle__start_date",
"issue_cycle__cycle__end_date",
"issue_module__module__name",
"issue_module__module__start_date",
"issue_module__module__target_date",
"created_by__first_name",
"created_by__last_name",
"assignees__first_name",
"assignees__last_name",
"labels__name",
)
)
# CSV header
header = [
"Issue ID",
"Project",
"Name",
"Description",
"State",
"Priority",
"Created By",
"Assignee",
"Labels",
"Cycle Name",
"Cycle Start Date",
"Cycle End Date",
"Module Name",
"Module Start Date",
"Module Target Date",
"Created At"
"Updated At"
"Completed At"
"Archived At"
]
# Prepare the CSV data
rows = [header]
# Write data for each issue
for issue in issues:
(
project_identifier,
sequence_id,
name,
description,
priority,
start_date,
target_date,
state_name,
project_name,
created_at,
updated_at,
completed_at,
archived_at,
cycle_name,
cycle_start_date,
cycle_end_date,
module_name,
module_start_date,
module_target_date,
created_by_first_name,
created_by_last_name,
assignees_first_names,
assignees_last_names,
labels_names,
) = issue
created_by_fullname = (
f"{created_by_first_name} {created_by_last_name}"
if created_by_first_name and created_by_last_name
else ""
)
assignees_names = ""
if assignees_first_names and assignees_last_names:
assignees_names = ", ".join(
[
f"{assignees_first_name} {assignees_last_name}"
for assignees_first_name, assignees_last_name in zip(
assignees_first_names, assignees_last_names
)
]
)
labels_names = ", ".join(labels_names) if labels_names else ""
row = [
f"{project_identifier}-{sequence_id}",
project_name,
name,
description,
state_name,
priority,
created_by_fullname,
assignees_names,
labels_names,
cycle_name,
cycle_start_date,
cycle_end_date,
module_name,
module_start_date,
module_target_date,
start_date,
target_date,
created_at,
updated_at,
completed_at,
archived_at,
]
rows.append(row)
# Create CSV file in-memory
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
# Write CSV data to the buffer
for row in rows:
writer.writerow(row)
subject = "Your Issue Export is ready"
context = {
"username": exporter_name,
}
html_content = render_to_string("emails/exports/issues.html", context)
text_content = strip_tags(html_content)
csv_buffer.seek(0)
msg = EmailMultiAlternatives(
subject, text_content, settings.EMAIL_FROM, [email]
)
msg.attach(f"{slug}-issues-{timezone.now().date()}.csv", csv_buffer.read(), "text/csv")
msg.send(fail_silently=False)
except Exception as e:
# Print logs if in DEBUG mode
if settings.DEBUG:
print(e)
capture_exception(e)
return

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,965 @@
# Generated by Django 4.2.3 on 2023-08-04 11:15
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.project
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0040_projectmember_preferences_user_cover_image_and_more'),
]
operations = [
migrations.AlterField(
model_name='analyticview',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='analyticview',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='apitoken',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='apitoken',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='cycle',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='cycle',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='cycle',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='cycle',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='cyclefavorite',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='cyclefavorite',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='cyclefavorite',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='cyclefavorite',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='cycleissue',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='cycleissue',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='cycleissue',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='cycleissue',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='estimate',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='estimate',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='estimate',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='estimate',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='estimatepoint',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='estimatepoint',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='estimatepoint',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='estimatepoint',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='fileasset',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='fileasset',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='githubcommentsync',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='githubcommentsync',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='githubcommentsync',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='githubcommentsync',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='githubissuesync',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='githubissuesync',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='githubissuesync',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='githubissuesync',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='githubrepository',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='githubrepository',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='githubrepository',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='githubrepository',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='githubrepositorysync',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='githubrepositorysync',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='githubrepositorysync',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='githubrepositorysync',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='importer',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='importer',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='importer',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='importer',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='inbox',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='inbox',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='inbox',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='inbox',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='inboxissue',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='inboxissue',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='inboxissue',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='inboxissue',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='integration',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='integration',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='issue',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='issue',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issue',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='issue',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issueactivity',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='issueactivity',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issueactivity',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='issueactivity',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issueassignee',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='issueassignee',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issueassignee',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='issueassignee',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issueattachment',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='issueattachment',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issueattachment',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='issueattachment',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issueblocker',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='issueblocker',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issueblocker',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='issueblocker',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issuecomment',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='issuecomment',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issuecomment',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='issuecomment',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issuelabel',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='issuelabel',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issuelabel',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='issuelabel',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issuelink',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='issuelink',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issuelink',
name='title',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='issuelink',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='issuelink',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issueproperty',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='issueproperty',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issueproperty',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='issueproperty',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issuesequence',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='issuesequence',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issuesequence',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='issuesequence',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issueview',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='issueview',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issueview',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='issueview',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='issueviewfavorite',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='issueviewfavorite',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='issueviewfavorite',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='issueviewfavorite',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='label',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='label',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='label',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='label',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='module',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='module',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='module',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='module',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='modulefavorite',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='modulefavorite',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='modulefavorite',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='modulefavorite',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='moduleissue',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='moduleissue',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='moduleissue',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='moduleissue',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='modulelink',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='modulelink',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='modulelink',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='modulelink',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='modulemember',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='modulemember',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='modulemember',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='modulemember',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='page',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='page',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='page',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='page',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='pageblock',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='pageblock',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='pageblock',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='pageblock',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='pagefavorite',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='pagefavorite',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='pagefavorite',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='pagefavorite',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='pagelabel',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='pagelabel',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='pagelabel',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='pagelabel',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='project',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='project',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='projectfavorite',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='projectfavorite',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='projectfavorite',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='projectfavorite',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='projectidentifier',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='projectidentifier',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='projectmember',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='projectmember',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='projectmember',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='projectmember',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='projectmemberinvite',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='projectmemberinvite',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='projectmemberinvite',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='projectmemberinvite',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='slackprojectsync',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='slackprojectsync',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='slackprojectsync',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='slackprojectsync',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='socialloginconnection',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='socialloginconnection',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='state',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='state',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project'),
),
migrations.AlterField(
model_name='state',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='state',
name='workspace',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace'),
),
migrations.AlterField(
model_name='team',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='team',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='teammember',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='teammember',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='workspace',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='workspace',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='workspaceintegration',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='workspaceintegration',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='workspacemember',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='workspacemember',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='workspacememberinvite',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='workspacememberinvite',
name='updated_by',
field=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'),
),
migrations.AlterField(
model_name='workspacetheme',
name='created_by',
field=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'),
),
migrations.AlterField(
model_name='workspacetheme',
name='updated_by',
field=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'),
),
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)),
('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')},
},
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 4.2.3 on 2023-08-09 12:15
import random
from django.db import migrations
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)
class Migration(migrations.Migration):
dependencies = [
('db', '0041_user_display_name_alter_analyticview_created_by_and_more'),
]
operations = [
migrations.RunPython(random_cycle_order),
migrations.RunPython(random_module_order),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 4.2.3 on 2023-08-09 11:15
from django.db import migrations
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", "0042_alter_analyticview_created_by_and_more"),
]
operations = [
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

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

@ -5,18 +5,23 @@ type Props = {
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.display_name ?? "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">
@ -38,7 +43,7 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
</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

@ -60,8 +60,10 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
lastName: user?.created_by__last_name, lastName: user?.created_by__last_name,
display_name: user?.created_by__display_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) => ({
@ -70,8 +72,10 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
lastName: user?.assignees__last_name, lastName: user?.assignees__last_name,
display_name: user?.assignees__display_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

@ -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,6 +24,8 @@ 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) => {

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

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 && (
<> <>
{" "} {" "}

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

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

@ -83,6 +83,7 @@ export const CustomThemeSelector: React.FC<Props> = observer(({ 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}
@ -120,6 +121,7 @@ export const CustomThemeSelector: React.FC<Props> = observer(({ 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}

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";
@ -140,7 +140,7 @@ 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" : ""
}`} }`}
> >
@ -155,11 +155,7 @@ export const BoardHeader: React.FC<Props> = ({
> >
{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>
@ -174,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

@ -232,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}
@ -301,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}
@ -347,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

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

@ -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,65 @@ 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) => {
const handleUpdateDates = async (data: any) => { if (!prevData) return prevData;
const payload = {
id: data?.id, const newList = prevData.map((p) => ({
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)
.finally(() => mutateCycles());
}; };
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

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

View File

@ -25,7 +25,7 @@ export const WeekChartView: 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 YearChartView: 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

@ -0,0 +1,14 @@
// types
import { IIssue } from "types";
import { IGanttBlock } from "components/gantt-chart";
export const renderIssueBlocksStructure = (blocks: IIssue[]): IGanttBlock[] =>
blocks && blocks.length > 0
? blocks.map((block) => ({
data: block,
id: block.id,
sort_order: block.sort_order,
start_date: new Date(block.start_date ?? ""),
target_date: new Date(block.target_date ?? ""),
}))
: [];

View File

@ -1,138 +1,155 @@
import { useState, useRef } from "react"; import React, { useRef, useState } from "react";
export const ChartDraggable = ({ children, block, handleBlock, className }: any) => { // react-beautiful-dnd
const [dragging, setDragging] = useState(false); import { DraggableProvided } from "react-beautiful-dnd";
import { useChart } from "../hooks";
// types
import { IGanttBlock } from "../types";
const [chartBlockPositionLeft, setChartBlockPositionLeft] = useState(0); type Props = {
const [blockPositionLeft, setBlockPositionLeft] = useState(0); children: any;
const [dragBlockOffsetX, setDragBlockOffsetX] = useState(0); block: IGanttBlock;
handleBlock: (totalBlockShifts: number, dragDirection: "left" | "right") => void;
enableLeftDrag: boolean;
enableRightDrag: boolean;
provided: DraggableProvided;
};
const handleMouseDown = (event: any) => { export const ChartDraggable: React.FC<Props> = ({
const chartBlockPositionLeft: number = block.position.marginLeft; children,
const blockPositionLeft: number = event.target.getBoundingClientRect().left; block,
const dragBlockOffsetX: number = event.clientX - event.target.getBoundingClientRect().left; handleBlock,
enableLeftDrag = true,
enableRightDrag = true,
provided,
}) => {
const [isLeftResizing, setIsLeftResizing] = useState(false);
const [isRightResizing, setIsRightResizing] = useState(false);
console.log("--------------------"); const parentDivRef = useRef<HTMLDivElement>(null);
console.log("chartBlockPositionLeft", chartBlockPositionLeft); const resizableRef = useRef<HTMLDivElement>(null);
console.log("blockPositionLeft", blockPositionLeft);
console.log("dragBlockOffsetX", dragBlockOffsetX);
console.log("-->");
setDragging(true); const { currentViewData } = useChart();
setChartBlockPositionLeft(chartBlockPositionLeft);
setBlockPositionLeft(blockPositionLeft);
setDragBlockOffsetX(dragBlockOffsetX);
};
const handleMouseMove = (event: any) => { const handleDrag = (dragDirection: "left" | "right") => {
if (!dragging) return; if (!currentViewData || !resizableRef.current || !parentDivRef.current || !block.position)
return;
const currentBlockPosition = event.clientX - dragBlockOffsetX; const resizableDiv = resizableRef.current;
console.log("currentBlockPosition", currentBlockPosition); const parentDiv = parentDivRef.current;
if (currentBlockPosition <= blockPositionLeft) {
const updatedPosition = chartBlockPositionLeft - (blockPositionLeft - currentBlockPosition);
console.log("updatedPosition", updatedPosition);
handleBlock({ ...block, position: { ...block.position, marginLeft: updatedPosition } });
} else {
const updatedPosition = chartBlockPositionLeft + (blockPositionLeft - currentBlockPosition);
console.log("updatedPosition", updatedPosition);
handleBlock({ ...block, position: { ...block.position, marginLeft: updatedPosition } });
}
console.log("--------------------");
};
const handleMouseUp = () => { const columnWidth = currentViewData.data.width;
setDragging(false);
setChartBlockPositionLeft(0); const blockInitialWidth =
setBlockPositionLeft(0); resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
setDragBlockOffsetX(0);
let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
let initialMarginLeft = block?.position?.marginLeft;
const handleMouseMove = (e: MouseEvent) => {
if (!window) return;
let delWidth = 0;
const posFromLeft = e.clientX;
const posFromRight = window.innerWidth - e.clientX;
const scrollContainer = document.querySelector("#scroll-container") as HTMLElement;
const appSidebar = document.querySelector("#app-sidebar") as HTMLElement;
// manually scroll to left if reached the left end while dragging
if (posFromLeft - appSidebar.clientWidth <= 70) {
if (e.movementX > 0) return;
delWidth = dragDirection === "left" ? -5 : 5;
scrollContainer.scrollBy(-1 * Math.abs(delWidth), 0);
} else delWidth = dragDirection === "left" ? -1 * e.movementX : e.movementX;
// manually scroll to right if reached the right end while dragging
if (posFromRight <= 70) {
if (e.movementX < 0) return;
delWidth = dragDirection === "left" ? -5 : 5;
scrollContainer.scrollBy(Math.abs(delWidth), 0);
} else delWidth = dragDirection === "left" ? -1 * e.movementX : e.movementX;
// calculate new width and update the initialMarginLeft using +=
const newWidth = Math.round((initialWidth += delWidth) / columnWidth) * columnWidth;
// block needs to be at least 1 column wide
if (newWidth < columnWidth) return;
resizableDiv.style.width = `${newWidth}px`;
if (block.position) block.position.width = newWidth;
// update the margin left of the block if dragging from the left end
if (dragDirection === "left") {
// calculate new marginLeft and update the initial marginLeft using -=
const newMarginLeft =
Math.round((initialMarginLeft -= delWidth) / columnWidth) * columnWidth;
parentDiv.style.marginLeft = `${newMarginLeft}px`;
if (block.position) block.position.marginLeft = newMarginLeft;
}
};
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
const totalBlockShifts = Math.ceil(
(resizableDiv.clientWidth - blockInitialWidth) / columnWidth
);
handleBlock(totalBlockShifts, dragDirection);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}; };
return ( return (
<div <div
onMouseDown={handleMouseDown} id={`block-${block.id}`}
onMouseMove={handleMouseMove} ref={parentDivRef}
onMouseUp={handleMouseUp} className="relative group inline-flex cursor-pointer items-center font-medium transition-all"
className={`${className ? className : ``}`} style={{
marginLeft: `${block.position?.marginLeft}px`,
}}
> >
{children} {enableLeftDrag && (
<>
<div
onMouseDown={() => handleDrag("left")}
onMouseEnter={() => setIsLeftResizing(true)}
onMouseLeave={() => setIsLeftResizing(false)}
className="absolute top-1/2 -left-2.5 -translate-y-1/2 z-[1] w-6 h-10 bg-brand-backdrop rounded-md cursor-col-resize"
/>
<div
className={`absolute top-1/2 -translate-y-1/2 w-1 h-4/5 rounded-sm bg-custom-background-80 transition-all duration-300 ${
isLeftResizing ? "-left-2.5" : "left-1"
}`}
/>
</>
)}
{React.cloneElement(children, { ref: resizableRef, ...provided.dragHandleProps })}
{enableRightDrag && (
<>
<div
onMouseDown={() => handleDrag("right")}
onMouseEnter={() => setIsRightResizing(true)}
onMouseLeave={() => setIsRightResizing(false)}
className="absolute top-1/2 -right-2.5 -translate-y-1/2 z-[1] w-6 h-6 bg-brand-backdrop rounded-md cursor-col-resize"
/>
<div
className={`absolute top-1/2 -translate-y-1/2 w-1 h-4/5 rounded-sm bg-custom-background-80 transition-all duration-300 ${
isRightResizing ? "-right-2.5" : "right-1"
}`}
/>
</>
)}
</div> </div>
); );
}; };
// import { useState } from "react";
// export const ChartDraggable = ({ children, id, className = "", style }: any) => {
// const [dragging, setDragging] = useState(false);
// const [chartBlockPositionLeft, setChartBlockPositionLeft] = useState(0);
// const [blockPositionLeft, setBlockPositionLeft] = useState(0);
// const [dragBlockOffsetX, setDragBlockOffsetX] = useState(0);
// const handleDragStart = (event: any) => {
// // event.dataTransfer.setData("text/plain", event.target.id);
// const chartBlockPositionLeft: number = parseInt(event.target.style.left.slice(0, -2));
// const blockPositionLeft: number = event.target.getBoundingClientRect().left;
// const dragBlockOffsetX: number = event.clientX - event.target.getBoundingClientRect().left;
// console.log("chartBlockPositionLeft", chartBlockPositionLeft);
// console.log("blockPositionLeft", blockPositionLeft);
// console.log("dragBlockOffsetX", dragBlockOffsetX);
// console.log("--------------------");
// setDragging(true);
// setChartBlockPositionLeft(chartBlockPositionLeft);
// setBlockPositionLeft(blockPositionLeft);
// setDragBlockOffsetX(dragBlockOffsetX);
// };
// const handleDragEnd = () => {
// setDragging(false);
// setChartBlockPositionLeft(0);
// setBlockPositionLeft(0);
// setDragBlockOffsetX(0);
// };
// const handleDragOver = (event: any) => {
// event.preventDefault();
// if (dragging) {
// const scrollContainer = document.getElementById(`block-parent-${id}`) as HTMLElement;
// const currentBlockPosition = event.clientX - dragBlockOffsetX;
// console.log('currentBlockPosition')
// if (currentBlockPosition <= blockPositionLeft) {
// const updatedPosition = chartBlockPositionLeft - (blockPositionLeft - currentBlockPosition);
// console.log("updatedPosition", updatedPosition);
// if (scrollContainer) scrollContainer.style.left = `${updatedPosition}px`;
// } else {
// const updatedPosition = chartBlockPositionLeft + (blockPositionLeft - currentBlockPosition);
// console.log("updatedPosition", updatedPosition);
// if (scrollContainer) scrollContainer.style.left = `${updatedPosition}px`;
// }
// console.log("--------------------");
// }
// };
// const handleDrop = (event: any) => {
// event.preventDefault();
// setDragging(false);
// setChartBlockPositionLeft(0);
// setBlockPositionLeft(0);
// setDragBlockOffsetX(0);
// };
// return (
// <div
// id={id}
// draggable
// onDragStart={handleDragStart}
// onDragEnd={handleDragEnd}
// onDragOver={handleDragOver}
// onDrop={handleDrop}
// className={`${className} ${dragging ? "dragging" : ""}`}
// style={style}
// >
// {children}
// </div>
// );
// };

View File

@ -0,0 +1 @@
export * from "./block-structure";

View File

@ -0,0 +1,43 @@
import { KeyedMutator } from "swr";
// services
import issuesService from "services/issues.service";
// types
import { ICurrentUserResponse, IIssue } from "types";
import { IBlockUpdateData } from "../types";
export const updateGanttIssue = (
issue: IIssue,
payload: IBlockUpdateData,
mutate: KeyedMutator<any>,
user: ICurrentUserResponse | undefined,
workspaceSlug: string | undefined
) => {
if (!issue || !workspaceSlug || !user) return;
mutate((prevData: IIssue[]) => {
if (!prevData) return prevData;
const newList = prevData.map((p) => ({
...p,
...(p.id === issue.id ? payload : {}),
}));
if (payload.sort_order) {
const removedElement = newList.splice(payload.sort_order.sourceIndex, 1)[0];
removedElement.sort_order = payload.sort_order.newSortOrder;
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;
issuesService
.patchIssue(workspaceSlug, issue.project, issue.id, newPayload, user)
.finally(() => mutate());
};

View File

@ -1 +1,5 @@
export * from "./blocks";
export * from "./helpers";
export * from "./hooks";
export * from "./root"; export * from "./root";
export * from "./types";

View File

@ -3,15 +3,20 @@ import { FC } from "react";
import { ChartViewRoot } from "./chart"; import { ChartViewRoot } from "./chart";
// context // context
import { ChartContextProvider } from "./contexts"; import { ChartContextProvider } from "./contexts";
// types
import { IBlockUpdateData, IGanttBlock } from "./types";
type GanttChartRootProps = { type GanttChartRootProps = {
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 GanttChartRoot: FC<GanttChartRootProps> = ({ export const GanttChartRoot: FC<GanttChartRootProps> = ({
@ -22,6 +27,9 @@ export const GanttChartRoot: FC<GanttChartRootProps> = ({
blockUpdateHandler, blockUpdateHandler,
sidebarBlockRender, sidebarBlockRender,
blockRender, blockRender,
enableLeftDrag = true,
enableRightDrag = true,
enableReorder = true,
}) => ( }) => (
<ChartContextProvider> <ChartContextProvider>
<ChartViewRoot <ChartViewRoot
@ -32,6 +40,9 @@ export const GanttChartRoot: FC<GanttChartRootProps> = ({
blockUpdateHandler={blockUpdateHandler} blockUpdateHandler={blockUpdateHandler}
sidebarBlockRender={sidebarBlockRender} sidebarBlockRender={sidebarBlockRender}
blockRender={blockRender} blockRender={blockRender}
enableLeftDrag={enableLeftDrag}
enableRightDrag={enableRightDrag}
enableReorder={enableReorder}
/> />
</ChartContextProvider> </ChartContextProvider>
); );

View File

@ -5,10 +5,32 @@ export type allViewsType = {
data: Object | null; data: Object | null;
}; };
export interface IGanttBlock {
data: any;
id: string;
position?: {
marginLeft: number;
width: number;
};
sort_order: number;
start_date: Date;
target_date: Date;
}
export interface IBlockUpdateData {
sort_order?: {
destinationIndex: number;
newSortOrder: number;
sourceIndex: number;
};
start_date?: string;
target_date?: string;
}
export interface ChartContextData { export interface ChartContextData {
allViews: allViewsType[]; allViews: allViewsType[];
currentView: "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year"; currentView: "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year";
currentViewData: any; currentViewData: ChartDataType | undefined;
renderView: any; renderView: any;
} }

View File

@ -1,5 +1,5 @@
// types // types
import { ChartDataType } from "../types"; import { ChartDataType, IGanttBlock } from "../types";
// data // data
import { weeks, months } from "../data"; import { weeks, months } from "../data";
// helpers // helpers
@ -19,7 +19,35 @@ type GetAllDaysInMonthInMonthViewType = {
active: boolean; active: boolean;
today: boolean; today: boolean;
}; };
const getAllDaysInMonthInMonthView = (month: number, year: number) => {
interface IMonthChild {
active: boolean;
date: Date;
day: number;
dayData: {
key: number;
shortTitle: string;
title: string;
};
title: string;
today: boolean;
weekNumber: number;
}
export interface IMonthBlock {
children: IMonthChild[];
month: number;
monthData: {
key: number;
shortTitle: string;
title: string;
};
title: string;
year: number;
}
[];
const getAllDaysInMonthInMonthView = (month: number, year: number): IMonthChild[] => {
const day: GetAllDaysInMonthInMonthViewType[] = []; const day: GetAllDaysInMonthInMonthViewType[] = [];
const numberOfDaysInMonth = getNumberOfDaysInMonth(month, year); const numberOfDaysInMonth = getNumberOfDaysInMonth(month, year);
const currentDate = new Date(); const currentDate = new Date();
@ -45,7 +73,7 @@ const getAllDaysInMonthInMonthView = (month: number, year: number) => {
return day; return day;
}; };
const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number) => { const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number): IMonthBlock => {
const currentMonth: number = month; const currentMonth: number = month;
const currentYear: number = year; const currentYear: number = year;
@ -162,7 +190,11 @@ export const getNumberOfDaysBetweenTwoDatesInMonth = (startDate: Date, endDate:
return daysDifference; return daysDifference;
}; };
export const getMonthChartItemPositionWidthInMonth = (chartData: ChartDataType, itemData: any) => { // calc item scroll position and width
export const getMonthChartItemPositionWidthInMonth = (
chartData: ChartDataType,
itemData: IGanttBlock
) => {
let scrollPosition: number = 0; let scrollPosition: number = 0;
let scrollWidth: number = 0; let scrollWidth: number = 0;

View File

@ -100,8 +100,6 @@ export const generateYearChart = (yearPayload: ChartDataType, side: null | "left
.map((monthData: any) => monthData.children.length) .map((monthData: any) => monthData.children.length)
.reduce((partialSum: number, a: number) => partialSum + a, 0) * yearPayload.data.width; .reduce((partialSum: number, a: number) => partialSum + a, 0) * yearPayload.data.width;
console.log("scrollWidth", scrollWidth);
return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth }; return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth };
}; };

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 useGanttChartIssues from "hooks/gantt-chart/issue-view"; import useGanttChartIssues from "hooks/gantt-chart/issue-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 IssueGanttChartView = () => {
export const IssueGanttChartView: FC<Props> = ({}) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { orderBy } = useIssuesView();
const { user } = useUser();
const { ganttIssues, mutateGanttIssues } = useGanttChartIssues( const { ganttIssues, mutateGanttIssues } = useGanttChartIssues(
workspaceSlug as string, workspaceSlug as string,
projectId as string projectId as string
@ -31,76 +38,19 @@ export const IssueGanttChartView: 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 font-normal">
<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,
};
};
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"> <div className="w-full h-full">
<GanttChartRoot <GanttChartRoot
border={false} border={false}
title="Issues" title="Issues"
loaderTitle="Issues" loaderTitle="Issues"
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

@ -18,7 +18,7 @@ export const ViewIssueLabel: React.FC<Props> = ({ issue, maxRender = 1 }) => (
{issue.label_details.map((label, index) => ( {issue.label_details.map((label, index) => (
<div <div
key={label.id} key={label.id}
className="flex cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm" className="flex cursor-default items-center flex-shrink-0 rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
> >
<Tooltip position="top" tooltipHeading="Label" tooltipContent={label.name}> <Tooltip position="top" tooltipHeading="Label" tooltipContent={label.name}>
<div className="flex items-center gap-1.5 text-custom-text-200"> <div className="flex items-center gap-1.5 text-custom-text-200">
@ -35,7 +35,7 @@ export const ViewIssueLabel: React.FC<Props> = ({ issue, maxRender = 1 }) => (
))} ))}
</> </>
) : ( ) : (
<div className="flex cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"> <div className="flex cursor-default items-center flex-shrink-0 rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm">
<Tooltip <Tooltip
position="top" position="top"
tooltipHeading="Labels" tooltipHeading="Labels"

View File

@ -248,7 +248,11 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
await addIssueToModule(res.id, payload.module); await addIssueToModule(res.id, payload.module);
if (issueView === "calendar") mutate(calendarFetchKey); if (issueView === "calendar") mutate(calendarFetchKey);
if (issueView === "gantt_chart") mutate(ganttFetchKey); if (issueView === "gantt_chart")
mutate(ganttFetchKey, {
start_target_date: true,
order_by: "sort_order",
});
if (issueView === "spreadsheet") mutate(spreadsheetFetchKey); if (issueView === "spreadsheet") mutate(spreadsheetFetchKey);
if (groupedIssues) mutateMyIssues(); if (groupedIssues) mutateMyIssues();

View File

@ -120,7 +120,7 @@ export const MyIssuesViewOptions: React.FC = () => {
{({ 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 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 ${
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"

View File

@ -93,13 +93,14 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user, disabled = false }
}); });
}; };
const completedSubIssues = subIssuesResponse const completedSubIssue = subIssuesResponse?.state_distribution.completed ?? 0;
? subIssuesResponse.state_distribution.completed + const cancelledSubIssue = subIssuesResponse?.state_distribution.cancelled ?? 0;
subIssuesResponse.state_distribution.cancelled
: 0; const totalCompletedSubIssues = completedSubIssue + cancelledSubIssue;
const totalSubIssues = subIssuesResponse ? subIssuesResponse.sub_issues.length : 0; const totalSubIssues = subIssuesResponse ? subIssuesResponse.sub_issues.length : 0;
const completionPercentage = (completedSubIssues / totalSubIssues) * 100; const completionPercentage = (totalCompletedSubIssues / totalSubIssues) * 100;
const isNotAllowed = memberRole.isGuest || memberRole.isViewer || disabled; const isNotAllowed = memberRole.isGuest || memberRole.isViewer || disabled;

View File

@ -8,7 +8,7 @@ import useSWR from "swr";
import projectService from "services/project.service"; import projectService from "services/project.service";
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
// ui // ui
import { AssigneesList, Avatar, CustomSearchSelect, Tooltip } from "components/ui"; import { AssigneesList, Avatar, CustomSearchSelect, Icon, Tooltip } from "components/ui";
// icons // icons
import { UserGroupIcon } from "@heroicons/react/24/outline"; import { UserGroupIcon } from "@heroicons/react/24/outline";
// types // types
@ -73,11 +73,11 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
> >
{issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? ( {issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? (
<div className="-my-0.5 flex items-center justify-center gap-2"> <div className="-my-0.5 flex items-center justify-center gap-2">
<AssigneesList userIds={issue.assignees} length={5} showLength={true} /> <AssigneesList userIds={issue.assignees} length={3} showLength={true} />
</div> </div>
) : ( ) : (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2 px-1.5 py-1 rounded shadow-sm border border-custom-border-300">
<UserGroupIcon className="h-4 w-4 text-custom-text-200" /> <Icon iconName="person" className="text-sm !leading-4" />
</div> </div>
)} )}
</div> </div>
@ -87,6 +87,7 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
return ( return (
<CustomSearchSelect <CustomSearchSelect
value={issue.assignees} value={issue.assignees}
buttonClassName="!p-0"
onChange={(data: any) => { onChange={(data: any) => {
const newData = issue.assignees ?? []; const newData = issue.assignees ?? [];

View File

@ -67,14 +67,8 @@ export const ViewPrioritySelect: React.FC<Props> = ({
noBorder noBorder
? "" ? ""
: issue.priority === "urgent" : issue.priority === "urgent"
? "border-red-500/20 bg-red-500/20" ? "border-red-500/20 bg-red-500"
: issue.priority === "high" : "border-custom-border-300 bg-custom-background-100"
? "border-orange-500/20 bg-orange-500/20"
: issue.priority === "medium"
? "border-yellow-500/20 bg-yellow-500/20"
: issue.priority === "low"
? "border-green-500/20 bg-green-500/20"
: "border-custom-border-200 bg-custom-background-80"
} items-center`} } items-center`}
> >
<Tooltip <Tooltip
@ -87,7 +81,7 @@ export const ViewPrioritySelect: React.FC<Props> = ({
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None", issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
`text-sm ${ `text-sm ${
issue.priority === "urgent" issue.priority === "urgent"
? "text-red-500" ? "text-white"
: issue.priority === "high" : issue.priority === "high"
? "text-orange-500" ? "text-orange-500"
: issue.priority === "medium" : issue.priority === "medium"

View File

@ -45,7 +45,7 @@ export const ViewStartDateSelect: React.FC<Props> = ({
> >
<div className="group flex-shrink-0 relative max-w-[6.5rem]"> <div className="group flex-shrink-0 relative max-w-[6.5rem]">
<CustomDatePicker <CustomDatePicker
placeholder="Due date" placeholder="Start date"
value={issue?.start_date} value={issue?.start_date}
onChange={(val) => { onChange={(val) => {
partialUpdateIssue( partialUpdateIssue(

View File

@ -74,9 +74,9 @@ export const ViewStateSelect: React.FC<Props> = ({
position={tooltipPosition} position={tooltipPosition}
> >
<div className="flex items-center cursor-pointer w-full gap-2 text-custom-text-200"> <div className="flex items-center cursor-pointer w-full gap-2 text-custom-text-200">
<span className="h-4 w-4"> <span className="h-3.5 w-3.5">
{selectedOption && {selectedOption &&
getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)} getStateGroupIcon(selectedOption.group, "14", "14", selectedOption.color)}
</span> </span>
<span className="truncate">{selectedOption?.name ?? "State"}</span> <span className="truncate">{selectedOption?.name ?? "State"}</span>
</div> </div>
@ -131,6 +131,7 @@ export const ViewStateSelect: React.FC<Props> = ({
disabled={isNotAllowed} disabled={isNotAllowed}
onOpen={() => setFetchStates(true)} onOpen={() => setFetchStates(true)}
noChevron noChevron
selfPositioned={selfPositioned}
/> />
); );
}; };

View File

@ -20,6 +20,7 @@ import { ChevronDownIcon } from "@heroicons/react/24/outline";
import type { ICurrentUserResponse, IIssueLabels, IState } from "types"; import type { ICurrentUserResponse, IIssueLabels, IState } from "types";
// constants // constants
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
import { LABEL_COLOR_OPTIONS, getRandomLabelColor } from "constants/label";
// types // types
type Props = { type Props = {
@ -52,10 +53,15 @@ export const CreateLabelModal: React.FC<Props> = ({
watch, watch,
control, control,
reset, reset,
setValue,
} = useForm<IIssueLabels>({ } = useForm<IIssueLabels>({
defaultValues, defaultValues,
}); });
useEffect(() => {
if (isOpen) setValue("color", getRandomLabelColor());
}, [setValue, isOpen]);
const onClose = () => { const onClose = () => {
handleClose(); handleClose();
reset(defaultValues); reset(defaultValues);
@ -156,6 +162,7 @@ export const CreateLabelModal: React.FC<Props> = ({
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<TwitterPicker <TwitterPicker
color={value} color={value}
colors={LABEL_COLOR_OPTIONS}
onChange={(value) => { onChange={(value) => {
onChange(value.hex); onChange(value.hex);
close(); close();

View File

@ -22,12 +22,14 @@ import { ChevronDownIcon } from "@heroicons/react/24/outline";
import { IIssueLabels } from "types"; import { IIssueLabels } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
import { getRandomLabelColor, LABEL_COLOR_OPTIONS } from "constants/label";
type Props = { type Props = {
labelForm: boolean; labelForm: boolean;
setLabelForm: React.Dispatch<React.SetStateAction<boolean>>; setLabelForm: React.Dispatch<React.SetStateAction<boolean>>;
isUpdating: boolean; isUpdating: boolean;
labelToUpdate: IIssueLabels | null; labelToUpdate: IIssueLabels | null;
onClose?: () => void;
}; };
const defaultValues: Partial<IIssueLabels> = { const defaultValues: Partial<IIssueLabels> = {
@ -35,167 +37,180 @@ const defaultValues: Partial<IIssueLabels> = {
color: "rgb(var(--color-text-200))", color: "rgb(var(--color-text-200))",
}; };
type Ref = HTMLDivElement; export const CreateUpdateLabelInline = forwardRef<HTMLDivElement, Props>(
function CreateUpdateLabelInline(props, ref) {
const { labelForm, setLabelForm, isUpdating, labelToUpdate, onClose } = props;
export const CreateUpdateLabelInline = forwardRef<Ref, Props>(function CreateUpdateLabelInline( const router = useRouter();
{ labelForm, setLabelForm, isUpdating, labelToUpdate }, const { workspaceSlug, projectId } = router.query;
ref
) {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { user } = useUserAuth(); const { user } = useUserAuth();
const { const {
handleSubmit, handleSubmit,
control, control,
register, register,
reset, reset,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
watch, watch,
setValue, setValue,
} = useForm<IIssueLabels>({ } = useForm<IIssueLabels>({
defaultValues, defaultValues,
}); });
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => { const handleClose = () => {
if (!workspaceSlug || !projectId || isSubmitting) return; setLabelForm(false);
reset(defaultValues);
if (onClose) onClose();
};
await issuesService const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
.createIssueLabel(workspaceSlug as string, projectId as string, formData, user) if (!workspaceSlug || !projectId || isSubmitting) return;
.then((res) => {
mutate<IIssueLabels[]>( await issuesService
PROJECT_ISSUE_LABELS(projectId as string), .createIssueLabel(workspaceSlug as string, projectId as string, formData, user)
(prevData) => [res, ...(prevData ?? [])], .then((res) => {
false mutate<IIssueLabels[]>(
PROJECT_ISSUE_LABELS(projectId as string),
(prevData) => [res, ...(prevData ?? [])],
false
);
handleClose();
});
};
const handleLabelUpdate: SubmitHandler<IIssueLabels> = async (formData) => {
if (!workspaceSlug || !projectId || isSubmitting) return;
await issuesService
.patchIssueLabel(
workspaceSlug as string,
projectId as string,
labelToUpdate?.id ?? "",
formData,
user
)
.then(() => {
reset(defaultValues);
mutate<IIssueLabels[]>(
PROJECT_ISSUE_LABELS(projectId as string),
(prevData) =>
prevData?.map((p) => (p.id === labelToUpdate?.id ? { ...p, ...formData } : p)),
false
);
handleClose();
});
};
useEffect(() => {
if (!labelForm && isUpdating) return;
reset();
}, [labelForm, isUpdating, reset]);
useEffect(() => {
if (!labelToUpdate) return;
setValue(
"color",
labelToUpdate.color && labelToUpdate.color !== "" ? labelToUpdate.color : "#000"
);
setValue("name", labelToUpdate.name);
}, [labelToUpdate, setValue]);
useEffect(() => {
if (labelToUpdate) {
setValue(
"color",
labelToUpdate.color && labelToUpdate.color !== "" ? labelToUpdate.color : "#000"
); );
reset(defaultValues); return;
setLabelForm(false); }
});
};
const handleLabelUpdate: SubmitHandler<IIssueLabels> = async (formData) => { setValue("color", getRandomLabelColor());
if (!workspaceSlug || !projectId || isSubmitting) return; }, [labelToUpdate, setValue]);
await issuesService return (
.patchIssueLabel( <div
workspaceSlug as string, className={`flex scroll-m-8 items-center gap-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5 ${
projectId as string, labelForm ? "" : "hidden"
labelToUpdate?.id ?? "", }`}
formData, ref={ref}
user
)
.then(() => {
reset(defaultValues);
mutate<IIssueLabels[]>(
PROJECT_ISSUE_LABELS(projectId as string),
(prevData) =>
prevData?.map((p) => (p.id === labelToUpdate?.id ? { ...p, ...formData } : p)),
false
);
setLabelForm(false);
});
};
useEffect(() => {
if (!labelForm && isUpdating) return;
reset();
}, [labelForm, isUpdating, reset]);
useEffect(() => {
if (!labelToUpdate) return;
setValue(
"color",
labelToUpdate.color && labelToUpdate.color !== "" ? labelToUpdate.color : "#000"
);
setValue("name", labelToUpdate.name);
}, [labelToUpdate, setValue]);
return (
<div
className={`flex scroll-m-8 items-center gap-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5 ${
labelForm ? "" : "hidden"
}`}
ref={ref}
>
<div className="flex-shrink-0">
<Popover className="relative z-10 flex h-full w-full items-center justify-center">
{({ open }) => (
<>
<Popover.Button
className={`group inline-flex items-center text-base font-medium focus:outline-none ${
open ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<span
className="h-5 w-5 rounded"
style={{
backgroundColor: watch("color"),
}}
/>
<ChevronDownIcon
className={`ml-2 h-5 w-5 group-hover:text-custom-text-200 ${
open ? "text-gray-600" : "text-gray-400"
}`}
aria-hidden="true"
/>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-full left-0 z-20 mt-3 w-screen max-w-xs px-2 sm:px-0">
<Controller
name="color"
control={control}
render={({ field: { value, onChange } }) => (
<TwitterPicker color={value} onChange={(value) => onChange(value.hex)} />
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<div className="flex flex-1 flex-col justify-center">
<Input
type="text"
id="labelName"
name="name"
register={register}
placeholder="Label title"
validations={{
required: "Label title is required",
}}
error={errors.name}
/>
</div>
<SecondaryButton
onClick={() => {
reset();
setLabelForm(false);
}}
> >
Cancel <div className="flex-shrink-0">
</SecondaryButton> <Popover className="relative z-10 flex h-full w-full items-center justify-center">
{isUpdating ? ( {({ open }) => (
<PrimaryButton onClick={handleSubmit(handleLabelUpdate)} loading={isSubmitting}> <>
{isSubmitting ? "Updating" : "Update"} <Popover.Button
</PrimaryButton> className={`group inline-flex items-center text-base font-medium focus:outline-none ${
) : ( open ? "text-custom-text-100" : "text-custom-text-200"
<PrimaryButton onClick={handleSubmit(handleLabelCreate)} loading={isSubmitting}> }`}
{isSubmitting ? "Adding" : "Add"} >
</PrimaryButton> <span
)} className="h-5 w-5 rounded"
</div> style={{
); backgroundColor: watch("color"),
}); }}
/>
<ChevronDownIcon
className={`ml-2 h-5 w-5 group-hover:text-custom-text-200 ${
open ? "text-gray-600" : "text-gray-400"
}`}
aria-hidden="true"
/>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-full left-0 z-20 mt-3 w-screen max-w-xs px-2 sm:px-0">
<Controller
name="color"
control={control}
render={({ field: { value, onChange } }) => (
<TwitterPicker
colors={LABEL_COLOR_OPTIONS}
color={value}
onChange={(value) => onChange(value.hex)}
/>
)}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<div className="flex flex-1 flex-col justify-center">
<Input
type="text"
id="labelName"
name="name"
register={register}
placeholder="Label title"
validations={{
required: "Label title is required",
}}
error={errors.name}
/>
</div>
<SecondaryButton onClick={() => handleClose()}>Cancel</SecondaryButton>
{isUpdating ? (
<PrimaryButton onClick={handleSubmit(handleLabelUpdate)} loading={isSubmitting}>
{isSubmitting ? "Updating" : "Update"}
</PrimaryButton>
) : (
<PrimaryButton onClick={handleSubmit(handleLabelCreate)} loading={isSubmitting}>
{isSubmitting ? "Adding" : "Add"}
</PrimaryButton>
)}
</div>
);
}
);

View File

@ -1,13 +1,20 @@
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";
// 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 useGanttChartModuleIssues from "hooks/gantt-chart/module-issues-view"; import useGanttChartModuleIssues from "hooks/gantt-chart/module-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 = {}; type Props = {};
@ -15,6 +22,10 @@ export const ModuleIssuesGanttChartView: FC<Props> = ({}) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query; const { workspaceSlug, projectId, moduleId } = router.query;
const { orderBy } = useIssuesView();
const { user } = useUser();
const { ganttIssues, mutateGanttIssues } = useGanttChartModuleIssues( const { ganttIssues, mutateGanttIssues } = useGanttChartModuleIssues(
workspaceSlug as string, workspaceSlug as string,
projectId as string, projectId as string,
@ -32,77 +43,18 @@ export const ModuleIssuesGanttChartView: 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="Modules" title="Modules"
loaderTitle="Modules" loaderTitle="Modules"
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

@ -1,11 +1,15 @@
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 modulesService from "services/modules.service";
// hooks
import useUser from "hooks/use-user";
// components // components
import { GanttChartRoot } from "components/gantt-chart"; import { GanttChartRoot, IBlockUpdateData, ModuleGanttBlock } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// types // types
import { IModule } from "types"; import { IModule } from "types";
// constants // constants
@ -13,11 +17,14 @@ import { MODULE_STATUS } from "constants/module";
type Props = { type Props = {
modules: IModule[]; modules: IModule[];
mutateModules: KeyedMutator<IModule[]>;
}; };
export const ModulesListGanttChartView: FC<Props> = ({ modules }) => { export const ModulesListGanttChartView: FC<Props> = ({ modules, mutateModules }) => {
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) => (
@ -32,42 +39,52 @@ export const ModulesListGanttChartView: FC<Props> = ({ modules }) => {
</div> </div>
); );
// rendering issues on gantt card const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => {
const GanttBlockView = ({ data }: { data: IModule }) => ( if (!workspaceSlug || !user) return;
<Link href={`/${workspaceSlug}/projects/${projectId}/modules/${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: MODULE_STATUS.find((s) => s.value === data.status)?.color }}
/>
<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 mutateModules((prevData) => {
const handleUpdateDates = async (data: any) => { if (!prevData) return prevData;
const payload = {
id: data?.id, const newList = prevData.map((p) => ({
start_date: data?.start_date, ...p,
target_date: data?.target_date, ...(p.id === module.id
}; ? {
start_date: payload.start_date ? payload.start_date : p.start_date,
target_date: payload.target_date ? payload.target_date : p.target_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;
modulesService
.patchModule(workspaceSlug.toString(), module.project, module.id, newPayload, user)
.finally(() => mutateModules());
}; };
const blockFormat = (blocks: any) => const blockFormat = (blocks: IModule[]) =>
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.target_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.target_date ?? ""),
}))
: []; : [];
return ( return (
@ -76,9 +93,9 @@ export const ModulesListGanttChartView: FC<Props> = ({ modules }) => {
title="Modules" title="Modules"
loaderTitle="Modules" loaderTitle="Modules"
blocks={modules ? blockFormat(modules) : null} blocks={modules ? blockFormat(modules) : null}
blockUpdateHandler={handleUpdateDates} blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)}
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />} sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
blockRender={(data: any) => <GanttBlockView data={data} />} blockRender={(data: any) => <ModuleGanttBlock module={data as IModule} />}
/> />
</div> </div>
); );

View File

@ -84,7 +84,7 @@ export const NotificationPopover = () => {
disabled={!store?.theme?.sidebarCollapsed} disabled={!store?.theme?.sidebarCollapsed}
> >
<Popover.Button <Popover.Button
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${ className={`relative group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
isActive isActive
? "bg-custom-primary-100/10 text-custom-primary-100" ? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80" : "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
@ -93,9 +93,13 @@ export const NotificationPopover = () => {
<NotificationsOutlined fontSize="small" /> <NotificationsOutlined fontSize="small" />
{store?.theme?.sidebarCollapsed ? null : <span>Notifications</span>} {store?.theme?.sidebarCollapsed ? null : <span>Notifications</span>}
{totalNotificationCount && totalNotificationCount > 0 ? ( {totalNotificationCount && totalNotificationCount > 0 ? (
<span className="ml-auto bg-custom-primary-300 rounded-full text-xs text-white px-1.5"> store?.theme?.sidebarCollapsed ? (
{getNumberCount(totalNotificationCount)} <span className="absolute right-3.5 top-2 h-2 w-2 bg-custom-primary-300 rounded-full" />
</span> ) : (
<span className="ml-auto bg-custom-primary-300 rounded-full text-xs text-white px-1.5">
{getNumberCount(totalNotificationCount)}
</span>
)
) : null} ) : null}
</Popover.Button> </Popover.Button>
</Tooltip> </Tooltip>
@ -108,7 +112,7 @@ export const NotificationPopover = () => {
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 bg-custom-background-100 flex flex-col left-0 md:left-full ml-8 z-10 top-0 md:w-[36rem] w-[20rem] h-[50vh] border border-custom-border-300 shadow-lg rounded-xl"> <Popover.Panel className="absolute bg-custom-background-100 flex flex-col left-0 md:left-full ml-8 z-10 -top-44 md:w-[36rem] w-[20rem] h-[75vh] border border-custom-border-300 shadow-lg rounded-xl">
<NotificationHeader <NotificationHeader
notificationCount={notificationCount} notificationCount={notificationCount}
notificationMutate={notificationMutate} notificationMutate={notificationMutate}

View File

@ -146,7 +146,7 @@ export const ProfileIssuesViewOptions: React.FC = () => {
{({ 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 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 ${
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"

View File

@ -1,4 +1,4 @@
import React, { useState, FC } from "react"; import React, { useState, FC, useRef, useEffect } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
@ -6,7 +6,7 @@ import { mutate } from "swr";
// react-beautiful-dnd // react-beautiful-dnd
import { DragDropContext, Draggable, DropResult, Droppable } from "react-beautiful-dnd"; import { DragDropContext, Draggable, DropResult, Droppable } from "react-beautiful-dnd";
// headless ui // headless ui
import { Disclosure } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useTheme from "hooks/use-theme"; import useTheme from "hooks/use-theme";
@ -36,6 +36,10 @@ export const ProjectSidebarList: FC = () => {
const [projectToDelete, setProjectToDelete] = useState<IProject | null>(null); const [projectToDelete, setProjectToDelete] = useState<IProject | null>(null);
// router // router
const [isScrolled, setIsScrolled] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -99,9 +103,7 @@ export const ProjectSidebarList: FC = () => {
? (projectsList[destination.index + 1].sort_order as number) ? (projectsList[destination.index + 1].sort_order as number)
: (projectsList[destination.index - 1].sort_order as number); : (projectsList[destination.index - 1].sort_order as number);
updatedSortOrder = Math.round( updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
(destinationSortingOrder + relativeDestinationSortingOrder) / 2
);
} }
mutate<IProject[]>( mutate<IProject[]>(
@ -126,6 +128,27 @@ export const ProjectSidebarList: FC = () => {
}); });
}; };
const handleScroll = () => {
if (containerRef.current) {
const scrollTop = containerRef.current.scrollTop;
setIsScrolled(scrollTop > 0);
}
};
useEffect(() => {
const currentContainerRef = containerRef.current;
if (currentContainerRef) {
currentContainerRef.addEventListener("scroll", handleScroll);
}
return () => {
if (currentContainerRef) {
currentContainerRef.removeEventListener("scroll", handleScroll);
}
};
}, []);
return ( return (
<> <>
<DeleteProjectModal <DeleteProjectModal
@ -134,7 +157,12 @@ export const ProjectSidebarList: FC = () => {
data={projectToDelete} data={projectToDelete}
user={user} user={user}
/> />
<div className="h-full overflow-y-auto px-4 space-y-3 pt-3 border-t border-custom-sidebar-border-300"> <div
ref={containerRef}
className={`h-full overflow-y-auto px-4 space-y-3 pt-3 ${
isScrolled ? "border-t border-custom-sidebar-border-300" : ""
}`}
>
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="favorite-projects"> <Droppable droppableId="favorite-projects">
{(provided) => ( {(provided) => (
@ -147,7 +175,7 @@ export const ProjectSidebarList: FC = () => {
<Disclosure.Button <Disclosure.Button
as="button" as="button"
type="button" type="button"
className="group flex items-center gap-1 px-1.5 text-xs font-semibold text-custom-sidebar-text-200 text-left hover:bg-custom-sidebar-background-80 rounded w-min whitespace-nowrap" className="group flex items-center gap-1 px-1.5 text-xs font-semibold text-custom-sidebar-text-400 text-left hover:bg-custom-sidebar-background-80 rounded w-full whitespace-nowrap"
> >
Favorites Favorites
<Icon <Icon
@ -199,37 +227,57 @@ export const ProjectSidebarList: FC = () => {
{({ open }) => ( {({ open }) => (
<> <>
{!store?.theme?.sidebarCollapsed && ( {!store?.theme?.sidebarCollapsed && (
<Disclosure.Button <div className="group flex justify-between items-center text-xs px-1.5 rounded text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 w-full">
as="button" <Disclosure.Button
type="button" as="button"
className="group flex items-center gap-1 px-1.5 text-xs font-semibold text-custom-sidebar-text-200 text-left hover:bg-custom-sidebar-background-80 rounded w-min whitespace-nowrap" type="button"
> className="flex items-center gap-1 font-semibold text-left whitespace-nowrap"
Projects >
<Icon Projects
iconName={open ? "arrow_drop_down" : "arrow_right"} <Icon
className="group-hover:opacity-100 opacity-0 !text-lg" iconName={open ? "arrow_drop_down" : "arrow_right"}
/> className="group-hover:opacity-100 opacity-0 !text-lg"
</Disclosure.Button> />
</Disclosure.Button>
<button
className="group-hover:opacity-100 opacity-0"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "p" });
document.dispatchEvent(e);
}}
>
<Icon iconName="add" />
</button>
</div>
)} )}
<Disclosure.Panel as="div" className="space-y-2"> <Transition
{orderedJoinedProjects.map((project, index) => ( enter="transition duration-100 ease-out"
<Draggable key={project.id} draggableId={project.id} index={index}> enterFrom="transform scale-95 opacity-0"
{(provided, snapshot) => ( enterTo="transform scale-100 opacity-100"
<div ref={provided.innerRef} {...provided.draggableProps}> leave="transition duration-75 ease-out"
<SingleSidebarProject leaveFrom="transform scale-100 opacity-100"
key={project.id} leaveTo="transform scale-95 opacity-0"
project={project} >
sidebarCollapse={store?.theme?.sidebarCollapsed} <Disclosure.Panel as="div" className="space-y-2">
provided={provided} {orderedJoinedProjects.map((project, index) => (
snapshot={snapshot} <Draggable key={project.id} draggableId={project.id} index={index}>
handleDeleteProject={() => handleDeleteProject(project)} {(provided, snapshot) => (
handleCopyText={() => handleCopyText(project.id)} <div ref={provided.innerRef} {...provided.draggableProps}>
/> <SingleSidebarProject
</div> key={project.id}
)} project={project}
</Draggable> sidebarCollapse={store?.theme?.sidebarCollapsed}
))} provided={provided}
</Disclosure.Panel> snapshot={snapshot}
handleDeleteProject={() => handleDeleteProject(project)}
handleCopyText={() => handleCopyText(project.id)}
/>
</div>
)}
</Draggable>
))}
</Disclosure.Panel>
</Transition>
{provided.placeholder} {provided.placeholder}
</> </>
)} )}
@ -239,43 +287,7 @@ export const ProjectSidebarList: FC = () => {
)} )}
</Droppable> </Droppable>
</DragDropContext> </DragDropContext>
{otherProjects && otherProjects.length > 0 && (
<Disclosure
as="div"
className="flex flex-col space-y-2"
defaultOpen={projectId && otherProjects.find((p) => p.id === projectId) ? true : false}
>
{({ open }) => (
<>
{!store?.theme?.sidebarCollapsed && (
<Disclosure.Button
as="button"
type="button"
className="group flex items-center gap-1 px-1.5 text-xs font-semibold text-custom-sidebar-text-200 text-left hover:bg-custom-sidebar-background-80 rounded w-min whitespace-nowrap"
>
Other Projects
<Icon
iconName={open ? "arrow_drop_down" : "arrow_right"}
className="group-hover:opacity-100 opacity-0 !text-lg"
/>
</Disclosure.Button>
)}
<Disclosure.Panel as="div" className="space-y-2">
{otherProjects?.map((project, index) => (
<SingleSidebarProject
key={project.id}
project={project}
sidebarCollapse={store?.theme?.sidebarCollapsed}
handleDeleteProject={() => handleDeleteProject(project)}
handleCopyText={() => handleCopyText(project.id)}
shortContextMenu
/>
))}
</Disclosure.Panel>
</>
)}
</Disclosure>
)}
{allProjects && allProjects.length === 0 && ( {allProjects && allProjects.length === 0 && (
<button <button
type="button" type="button"

View File

@ -210,7 +210,7 @@ export const SingleProjectCard: React.FC<ProjectCardProps> = ({
{project.is_favorite ? ( {project.is_favorite ? (
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}> <CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4" /> <StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
<span>Remove from favorites</span> <span>Remove from favorites</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>

View File

@ -12,7 +12,7 @@ import projectService from "services/project.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// ui // ui
import { CustomMenu, Tooltip } from "components/ui"; import { CustomMenu, Icon, Tooltip } from "components/ui";
// icons // icons
import { EllipsisVerticalIcon, LinkIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline"; import { EllipsisVerticalIcon, LinkIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
import { import {
@ -90,6 +90,8 @@ export const SingleSidebarProject: React.FC<Props> = ({
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const isAdmin = project.member_role === 20;
const handleAddToFavorites = () => { const handleAddToFavorites = () => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
@ -152,7 +154,7 @@ export const SingleSidebarProject: React.FC<Props> = ({
> >
<button <button
type="button" type="button"
className={`absolute top-1/2 -translate-y-1/2 -left-4 hidden rounded p-0.5 ${ className={`absolute top-1/2 -translate-y-1/2 -left-4 hidden rounded p-0.5 text-custom-sidebar-text-400 ${
sidebarCollapse ? "" : "group-hover:!flex" sidebarCollapse ? "" : "group-hover:!flex"
} ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""}`} } ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""}`}
{...provided?.dragHandleProps} {...provided?.dragHandleProps}
@ -204,15 +206,19 @@ export const SingleSidebarProject: React.FC<Props> = ({
fontSize="small" fontSize="small"
className={`flex-shrink-0 ${ className={`flex-shrink-0 ${
open ? "rotate-180" : "" open ? "rotate-180" : ""
} !hidden group-hover:!block text-custom-sidebar-text-200 duration-300`} } !hidden group-hover:!block text-custom-sidebar-text-400 duration-300`}
/> />
)} )}
</Disclosure.Button> </Disclosure.Button>
</Tooltip> </Tooltip>
{!sidebarCollapse && ( {!sidebarCollapse && (
<CustomMenu className="hidden group-hover:block flex-shrink-0" ellipsis> <CustomMenu
{!shortContextMenu && ( className="hidden group-hover:block flex-shrink-0"
buttonClassName="!text-custom-sidebar-text-400 hover:text-custom-sidebar-text-400"
ellipsis
>
{!shortContextMenu && isAdmin && (
<CustomMenu.MenuItem onClick={handleDeleteProject}> <CustomMenu.MenuItem onClick={handleDeleteProject}>
<span className="flex items-center justify-start gap-2 "> <span className="flex items-center justify-start gap-2 ">
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
@ -231,7 +237,7 @@ export const SingleSidebarProject: React.FC<Props> = ({
{project.is_favorite && ( {project.is_favorite && (
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}> <CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<StarIcon className="h-4 w-4" /> <StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
<span>Remove from favorites</span> <span>Remove from favorites</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
@ -254,6 +260,14 @@ export const SingleSidebarProject: React.FC<Props> = ({
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
<CustomMenu.MenuItem
onClick={() => router.push(`/${workspaceSlug}/projects/${project?.id}/settings`)}
>
<div className="flex items-center justify-start gap-2">
<Icon iconName="settings" className="!text-base !leading-4" />
<span>Settings</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu> </CustomMenu>
)} )}
</div> </div>

View File

@ -3,6 +3,8 @@ import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// component
import { Icon } from "components/ui";
// services // services
import workspaceService from "services/workspace.service"; import workspaceService from "services/workspace.service";
// icons // icons
@ -23,12 +25,14 @@ type AvatarProps = {
export const Avatar: React.FC<AvatarProps> = ({ export const Avatar: React.FC<AvatarProps> = ({
user, user,
index, index,
height = "20px", height = "24px",
width = "20px", width = "24px",
fontSize = "12px", fontSize = "12px",
}) => ( }) => (
<div <div
className={`relative rounded-full ${index && index !== 0 ? "-ml-3.5" : ""}`} className={`relative rounded border-[0.5px] ${
index && index !== 0 ? "-ml-3.5 border-custom-border-200" : "border-transparent"
}`}
style={{ style={{
height: height, height: height,
width: width, width: width,
@ -36,8 +40,8 @@ export const Avatar: React.FC<AvatarProps> = ({
> >
{user && user.avatar && user.avatar !== "" ? ( {user && user.avatar && user.avatar !== "" ? (
<div <div
className={`rounded-full border-2 ${ className={`rounded border-[0.5px] ${
index ? "border-custom-border-200 bg-custom-background-80" : "border-transparent" index ? "border-custom-border-200 bg-custom-background-100" : "border-transparent"
}`} }`}
style={{ style={{
height: height, height: height,
@ -46,13 +50,13 @@ export const Avatar: React.FC<AvatarProps> = ({
> >
<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"
alt={user.display_name} alt={user.display_name}
/> />
</div> </div>
) : ( ) : (
<div <div
className="grid place-items-center rounded-full border-2 border-custom-border-200 bg-gray-700 text-xs capitalize text-white" className="grid place-items-center text-xs capitalize text-white rounded bg-gray-700 border-[0.5px] border-custom-border-200"
style={{ style={{
height: height, height: height,
width: width, width: width,
@ -75,7 +79,7 @@ type AsigneesListProps = {
export const AssigneesList: React.FC<AsigneesListProps> = ({ export const AssigneesList: React.FC<AsigneesListProps> = ({
users, users,
userIds, userIds,
length = 5, length = 3,
showLength = true, showLength = true,
}) => { }) => {
const router = useRouter(); const router = useRouter();
@ -88,7 +92,7 @@ export const AssigneesList: React.FC<AsigneesListProps> = ({
if ((users && users.length === 0) || (userIds && userIds.length === 0)) if ((users && users.length === 0) || (userIds && userIds.length === 0))
return ( return (
<div className="h-5 w-5 rounded-full border-2 border-white bg-custom-background-80"> <div className="h-5 w-5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-80">
<Image src={User} height="100%" width="100%" className="rounded-full" alt="No user" /> <Image src={User} height="100%" width="100%" className="rounded-full" alt="No user" />
</div> </div>
); );
@ -100,7 +104,14 @@ export const AssigneesList: React.FC<AsigneesListProps> = ({
{users.slice(0, length).map((user, index) => ( {users.slice(0, length).map((user, index) => (
<Avatar key={user?.id} user={user} index={index} /> <Avatar key={user?.id} user={user} index={index} />
))} ))}
{users.length > length ? <span>+{users.length - length}</span> : null} {users.length > length ? (
<div className="-ml-3.5 relative h-6 w-6 rounded">
<div className="grid place-items-center rounded bg-custom-background-80 text-xs capitalize h-6 w-6 text-custom-text-200 border-[0.5px] border-custom-border-300">
<Icon iconName="add" className="text-xs !leading-3 -mr-0.5" />
{users.length - length}
</div>
</div>
) : null}
</> </>
)} )}
{userIds && ( {userIds && (
@ -112,7 +123,12 @@ export const AssigneesList: React.FC<AsigneesListProps> = ({
})} })}
{showLength ? ( {showLength ? (
userIds.length > length ? ( userIds.length > length ? (
<span>+{userIds.length - length}</span> <div className="-ml-3.5 relative h-6 w-6 rounded">
<div className="flex items-center rounded bg-custom-background-80 text-xs capitalize h-6 w-6 text-custom-text-200 border-[0.5px] border-custom-border-300">
<Icon iconName="add" className="text-xs !leading-3 -mr-0.5" />
{userIds.length - length}
</div>
</div>
) : null ) : null
) : ( ) : (
"" ""

View File

@ -1,13 +1,19 @@
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";
// components
import { GanttChartRoot } from "components/gantt-chart";
// ui
import { Tooltip } from "components/ui";
// hooks // hooks
import useGanttChartViewIssues from "hooks/gantt-chart/view-issues-view"; import useGanttChartViewIssues from "hooks/gantt-chart/view-issues-view";
import useUser from "hooks/use-user";
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 = {}; type Props = {};
@ -15,6 +21,8 @@ export const ViewIssuesGanttChartView: FC<Props> = ({}) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query; const { workspaceSlug, projectId, viewId } = router.query;
const { user } = useUser();
const { ganttIssues, mutateGanttIssues } = useGanttChartViewIssues( const { ganttIssues, mutateGanttIssues } = useGanttChartViewIssues(
workspaceSlug as string, workspaceSlug as string,
projectId as string, projectId as string,
@ -32,77 +40,17 @@ export const ViewIssuesGanttChartView: 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="Issue Views" title="Issue Views"
loaderTitle="Issue Views" loaderTitle="Issue Views"
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} />}
/> />
</div> </div>
); );

View File

@ -50,8 +50,10 @@ export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, u
const canDelete = confirmWorkspaceName === data?.name && confirmDeleteMyWorkspace; const canDelete = confirmWorkspaceName === data?.name && confirmDeleteMyWorkspace;
const handleClose = () => { const handleClose = () => {
onClose();
setIsDeleteLoading(false); setIsDeleteLoading(false);
setConfirmWorkspaceName("");
setConfirmDeleteMyWorkspace(false);
onClose();
}; };
const handleDeletion = async () => { const handleDeletion = async () => {

View File

@ -9,3 +9,4 @@ export * from "./issues-stats";
export * from "./settings-header"; export * from "./settings-header";
export * from "./sidebar-dropdown"; export * from "./sidebar-dropdown";
export * from "./sidebar-menu"; export * from "./sidebar-menu";
export * from "./sidebar-quick-action";

View File

@ -146,13 +146,12 @@ export const WorkspaceSidebarDropdown = () => {
> >
<Menu.Items <Menu.Items
className="fixed left-4 z-20 mt-1 flex flex-col w-full max-w-[17rem] origin-top-left rounded-md className="fixed left-4 z-20 mt-1 flex flex-col w-full max-w-[17rem] origin-top-left rounded-md
border border-custom-sidebar-border-200 bg-custom-sidebar-background-90 shadow-lg outline-none" border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 shadow-lg outline-none"
> >
<div className="flex flex-col items-start justify-start gap-3 p-3"> <div className="flex flex-col items-start justify-start gap-3 p-3">
<div className="text-sm text-custom-sidebar-text-200">{user?.display_name}</div> <span className="text-sm font-medium text-custom-sidebar-text-200">Workspace</span>
<span className="text-sm font-semibold text-custom-sidebar-text-200">Workspace</span>
{workspaces ? ( {workspaces ? (
<div className="flex h-full w-full flex-col items-start justify-start gap-3.5"> <div className="flex h-full w-full flex-col items-start justify-start gap-1.5">
{workspaces.length > 0 ? ( {workspaces.length > 0 ? (
workspaces.map((workspace) => ( workspaces.map((workspace) => (
<Menu.Item key={workspace.id}> <Menu.Item key={workspace.id}>
@ -160,7 +159,7 @@ export const WorkspaceSidebarDropdown = () => {
<button <button
type="button" type="button"
onClick={() => handleWorkspaceNavigation(workspace)} onClick={() => handleWorkspaceNavigation(workspace)}
className="flex w-full items-center justify-between gap-1 rounded-md text-sm text-custom-sidebar-text-100" className="flex w-full items-center justify-between gap-1 p-1 rounded-md text-sm text-custom-sidebar-text-100 hover:bg-custom-sidebar-background-80"
> >
<div className="flex items-center justify-start gap-2.5"> <div className="flex items-center justify-start gap-2.5">
<span className="relative flex h-6 w-6 items-center justify-center rounded bg-gray-700 p-2 text-xs uppercase text-white"> <span className="relative flex h-6 w-6 items-center justify-center rounded bg-gray-700 p-2 text-xs uppercase text-white">
@ -186,9 +185,7 @@ export const WorkspaceSidebarDropdown = () => {
<span className="p-1"> <span className="p-1">
<CheckIcon <CheckIcon
className={`h-3 w-3.5 text-custom-sidebar-text-100 ${ className={`h-3 w-3.5 text-custom-sidebar-text-100 ${
active || workspace.id === activeWorkspace?.id workspace.id === activeWorkspace?.id ? "opacity-100" : "opacity-0"
? "opacity-100"
: "opacity-0"
}`} }`}
/> />
</span> </span>
@ -205,9 +202,9 @@ export const WorkspaceSidebarDropdown = () => {
onClick={() => { onClick={() => {
router.push("/create-workspace"); router.push("/create-workspace");
}} }}
className="flex w-full items-center gap-1 text-sm text-custom-sidebar-text-200" className="flex w-full items-center gap-2 px-2 py-1 text-sm text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
> >
<PlusIcon className="h-3 w-3" /> <PlusIcon className="h-4 w-4" />
Create Workspace Create Workspace
</Menu.Item> </Menu.Item>
</div> </div>
@ -263,16 +260,17 @@ export const WorkspaceSidebarDropdown = () => {
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items <Menu.Items
className="absolute left-0 z-20 mt-1.5 flex flex-col w-52 origin-top-left rounded-md className="absolute left-0 z-20 mt-1.5 flex flex-col w-52 origin-top-left rounded-md
border border-custom-sidebar-border-200 bg-custom-sidebar-background-90 p-2 divide-y divide-custom-sidebar-border-200 shadow-lg text-xs outline-none" border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 divide-y divide-custom-sidebar-border-200 shadow-lg text-xs outline-none"
> >
<div className="flex flex-col space-y-2 pb-2"> <div className="flex flex-col gap-2.5 pb-2">
<span className="px-2 text-custom-sidebar-text-200">{user?.email}</span>
{profileLinks(workspaceSlug?.toString() ?? "", user?.id ?? "").map( {profileLinks(workspaceSlug?.toString() ?? "", user?.id ?? "").map(
(link, index) => ( (link, index) => (
<Menu.Item key={index} as="button" type="button"> <Menu.Item key={index} as="button" type="button">
<Link href={link.link}> <Link href={link.link}>
<a className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"> <a className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
<Icon iconName={link.icon} className="!text-base" /> <Icon iconName={link.icon} className="!text-lg !leading-5" />
{link.name} {link.name}
</a> </a>
</Link> </Link>
@ -287,7 +285,7 @@ export const WorkspaceSidebarDropdown = () => {
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80" className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
onClick={handleSignOut} onClick={handleSignOut}
> >
<Icon iconName="logout" className="!text-base" /> <Icon iconName="logout" className="!text-lg !leading-5" />
Sign out Sign out
</Menu.Item> </Menu.Item>
</div> </div>

View File

@ -51,7 +51,7 @@ export const WorkspaceSidebarMenu = () => {
const { collapsed: sidebarCollapse } = useTheme(); const { collapsed: sidebarCollapse } = useTheme();
return ( return (
<div className="w-full cursor-pointer space-y-2 px-4 mt-5 pb-5"> <div className="w-full cursor-pointer space-y-1 p-4">
{workspaceLinks(workspaceSlug as string).map((link, index) => { {workspaceLinks(workspaceSlug as string).map((link, index) => {
const isActive = const isActive =
link.name === "Settings" link.name === "Settings"

View File

@ -0,0 +1,47 @@
import React from "react";
// ui
import { Icon } from "components/ui";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
export const WorkspaceSidebarQuickAction = () => {
const store: any = useMobxStore();
return (
<div
className={`flex items-center justify-between w-full cursor-pointer px-4 mt-4 ${
store?.theme?.sidebarCollapsed ? "flex-col gap-1" : "gap-2"
}`}
>
<button
className={`flex items-center gap-2 flex-grow rounded flex-shrink-0 py-2 ${
store?.theme?.sidebarCollapsed
? "px-2 hover:bg-custom-sidebar-background-80"
: "px-3 shadow border-[0.5px] border-custom-border-300"
}`}
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<Icon iconName="edit_square" className="!text-xl !leading-5 text-custom-sidebar-text-300" />
{!store?.theme?.sidebarCollapsed && <span className="text-sm font-medium">New Issue</span>}
</button>
<button
className={`flex items-center justify-center rounded flex-shrink-0 p-2 ${
store?.theme?.sidebarCollapsed
? "hover:bg-custom-sidebar-background-80"
: "shadow border-[0.5px] border-custom-border-300"
}`}
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "k", ctrlKey: true, metaKey: true });
document.dispatchEvent(e);
}}
>
<Icon iconName="search" className="!text-xl !leading-5 text-custom-sidebar-text-300" />
</button>
</div>
);
};

View File

@ -2,13 +2,23 @@ import { objToQueryParams } from "helpers/string.helper";
import { IAnalyticsParams, IJiraMetadata, INotificationParams } from "types"; import { IAnalyticsParams, IJiraMetadata, INotificationParams } from "types";
const paramsToKey = (params: any) => { const paramsToKey = (params: any) => {
const { state, priority, assignees, created_by, labels, target_date, sub_issue } = params; const {
state,
priority,
assignees,
created_by,
labels,
target_date,
sub_issue,
start_target_date,
} = params;
let stateKey = state ? state.split(",") : []; let stateKey = state ? state.split(",") : [];
let priorityKey = priority ? priority.split(",") : []; let priorityKey = priority ? priority.split(",") : [];
let assigneesKey = assignees ? assignees.split(",") : []; let assigneesKey = assignees ? assignees.split(",") : [];
let createdByKey = created_by ? created_by.split(",") : []; let createdByKey = created_by ? created_by.split(",") : [];
let labelsKey = labels ? labels.split(",") : []; let labelsKey = labels ? labels.split(",") : [];
const startTargetDate = start_target_date ? `${start_target_date}`.toUpperCase() : "FALSE";
const targetDateKey = target_date ?? ""; const targetDateKey = target_date ?? "";
const type = params.type ? params.type.toUpperCase() : "NULL"; const type = params.type ? params.type.toUpperCase() : "NULL";
const groupBy = params.group_by ? params.group_by.toUpperCase() : "NULL"; const groupBy = params.group_by ? params.group_by.toUpperCase() : "NULL";
@ -21,7 +31,7 @@ const paramsToKey = (params: any) => {
createdByKey = createdByKey.sort().join("_"); createdByKey = createdByKey.sort().join("_");
labelsKey = labelsKey.sort().join("_"); labelsKey = labelsKey.sort().join("_");
return `${stateKey}_${priorityKey}_${assigneesKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${targetDateKey}_${sub_issue}`; return `${stateKey}_${priorityKey}_${assigneesKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${targetDateKey}_${sub_issue}_${startTargetDate}`;
}; };
const inboxParamsToKey = (params: any) => { const inboxParamsToKey = (params: any) => {

View File

@ -0,0 +1,17 @@
export const LABEL_COLOR_OPTIONS = [
"#FF6900",
"#FCB900",
"#7BDCB5",
"#00D084",
"#8ED1FC",
"#0693E3",
"#ABB8C3",
"#EB144C",
"#F78DA7",
"#9900EF",
];
export const getRandomLabelColor = () => {
const randomIndex = Math.floor(Math.random() * LABEL_COLOR_OPTIONS.length);
return LABEL_COLOR_OPTIONS[randomIndex];
};

View File

@ -43,6 +43,14 @@ export const SPREADSHEET_COLUMN = [
ascendingOrder: "labels__name", ascendingOrder: "labels__name",
descendingOrder: "-labels__name", descendingOrder: "-labels__name",
}, },
{
propertyName: "start_date",
colName: "Start Date",
colSize: "128px",
icon: CalendarDaysIcon,
ascendingOrder: "-start_date",
descendingOrder: "start_date",
},
{ {
propertyName: "due_date", propertyName: "due_date",
colName: "Due Date", colName: "Due Date",

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