mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of https://github.com/makeplane/plane into feat/csv_exporter
This commit is contained in:
commit
987806258a
@ -18,6 +18,8 @@ from .project import (
|
||||
ProjectFavoriteSerializer,
|
||||
ProjectLiteSerializer,
|
||||
ProjectMemberLiteSerializer,
|
||||
ProjectDeployBoardSerializer,
|
||||
ProjectMemberAdminSerializer,
|
||||
)
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
|
||||
@ -41,6 +43,7 @@ from .issue import (
|
||||
IssueSubscriberSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
IssueVoteSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
@ -78,3 +81,5 @@ from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSeriali
|
||||
from .analytic import AnalyticViewSerializer
|
||||
|
||||
from .notification import NotificationSerializer
|
||||
|
||||
from .exporter import ExporterHistorySerializer
|
||||
|
@ -41,6 +41,7 @@ class CycleSerializer(BaseSerializer):
|
||||
{
|
||||
"avatar": assignee.avatar,
|
||||
"first_name": assignee.first_name,
|
||||
"display_name": assignee.display_name,
|
||||
"id": assignee.id,
|
||||
}
|
||||
for issue_cycle in obj.issue_cycle.all()
|
||||
|
26
apiserver/plane/api/serializers/exporter.py
Normal file
26
apiserver/plane/api/serializers/exporter.py
Normal 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
|
@ -31,6 +31,7 @@ from plane.db.models import (
|
||||
IssueAttachment,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueVote,
|
||||
)
|
||||
|
||||
|
||||
@ -111,6 +112,11 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
"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):
|
||||
blockers = validated_data.pop("blockers_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):
|
||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
|
||||
@ -568,6 +582,7 @@ class IssueCommentSerializer(BaseSerializer):
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"access",
|
||||
]
|
||||
|
||||
|
||||
|
@ -14,6 +14,7 @@ from plane.db.models import (
|
||||
ProjectMemberInvite,
|
||||
ProjectIdentifier,
|
||||
ProjectFavorite,
|
||||
ProjectDeployBoard,
|
||||
)
|
||||
|
||||
|
||||
@ -80,7 +81,14 @@ class ProjectSerializer(BaseSerializer):
|
||||
class ProjectLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = ["id", "identifier", "name"]
|
||||
fields = [
|
||||
"id",
|
||||
"identifier",
|
||||
"name",
|
||||
"cover_image",
|
||||
"icon_prop",
|
||||
"emoji",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@ -94,6 +102,7 @@ class ProjectDetailSerializer(BaseSerializer):
|
||||
total_modules = serializers.IntegerField(read_only=True)
|
||||
is_member = serializers.BooleanField(read_only=True)
|
||||
sort_order = serializers.FloatField(read_only=True)
|
||||
member_role = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
@ -115,7 +124,6 @@ class ProjectMemberAdminSerializer(BaseSerializer):
|
||||
project = ProjectLiteSerializer(read_only=True)
|
||||
member = UserAdminLiteSerializer(read_only=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
model = ProjectMember
|
||||
fields = "__all__"
|
||||
@ -148,8 +156,6 @@ class ProjectFavoriteSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
class ProjectMemberLiteSerializer(BaseSerializer):
|
||||
member = UserLiteSerializer(read_only=True)
|
||||
is_subscribed = serializers.BooleanField(read_only=True)
|
||||
@ -158,3 +164,16 @@ class ProjectMemberLiteSerializer(BaseSerializer):
|
||||
model = ProjectMember
|
||||
fields = ["member", "id", "is_subscribed"]
|
||||
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",
|
||||
]
|
||||
|
@ -86,6 +86,7 @@ from plane.api.views import (
|
||||
IssueAttachmentEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
IssueCommentPublicViewSet,
|
||||
IssueReactionViewSet,
|
||||
CommentReactionViewSet,
|
||||
ExportIssuesEndpoint,
|
||||
@ -165,6 +166,15 @@ from plane.api.views import (
|
||||
NotificationViewSet,
|
||||
UnreadNotificationEndpoint,
|
||||
## End Notification
|
||||
# Public Boards
|
||||
ProjectDeployBoardViewSet,
|
||||
ProjectDeployBoardIssuesPublicEndpoint,
|
||||
ProjectDeployBoardPublicSettingsEndpoint,
|
||||
IssueReactionPublicViewSet,
|
||||
CommentReactionPublicViewSet,
|
||||
InboxIssuePublicViewSet,
|
||||
IssueVotePublicViewSet,
|
||||
## End Public Boards
|
||||
)
|
||||
|
||||
|
||||
@ -1481,4 +1491,128 @@ urlpatterns = [
|
||||
name="unread-notifications",
|
||||
),
|
||||
## 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
|
||||
]
|
||||
|
@ -12,6 +12,9 @@ from .project import (
|
||||
ProjectUserViewsEndpoint,
|
||||
ProjectMemberUserEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
ProjectDeployBoardIssuesPublicEndpoint,
|
||||
ProjectDeployBoardViewSet,
|
||||
ProjectDeployBoardPublicSettingsEndpoint,
|
||||
ProjectMemberEndpoint,
|
||||
)
|
||||
from .user import (
|
||||
@ -75,9 +78,12 @@ from .issue import (
|
||||
IssueAttachmentEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
IssueCommentPublicViewSet,
|
||||
CommentReactionViewSet,
|
||||
IssueReactionViewSet,
|
||||
ExportIssuesEndpoint
|
||||
IssueReactionPublicViewSet,
|
||||
CommentReactionPublicViewSet,
|
||||
IssueVotePublicViewSet,
|
||||
)
|
||||
|
||||
from .auth_extended import (
|
||||
@ -145,7 +151,7 @@ from .estimate import (
|
||||
|
||||
from .release import ReleaseNotesEndpoint
|
||||
|
||||
from .inbox import InboxViewSet, InboxIssueViewSet
|
||||
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
|
||||
|
||||
from .analytic import (
|
||||
AnalyticsEndpoint,
|
||||
@ -155,4 +161,8 @@ from .analytic import (
|
||||
DefaultAnalyticsEndpoint,
|
||||
)
|
||||
|
||||
from .notification import NotificationViewSet, UnreadNotificationEndpoint
|
||||
from .notification import NotificationViewSet, UnreadNotificationEndpoint
|
||||
|
||||
from .exporter import (
|
||||
ExportIssuesEndpoint,
|
||||
)
|
@ -243,21 +243,21 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
|
||||
)
|
||||
most_issue_created_user = (
|
||||
queryset.exclude(created_by=None)
|
||||
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__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"))
|
||||
.order_by("-count")
|
||||
)[:5]
|
||||
|
||||
most_issue_closed_user = (
|
||||
queryset.filter(completed_at__isnull=False, assignees__isnull=False)
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name")
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name", "assignees__id")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)[:5]
|
||||
|
||||
pending_issue_user = (
|
||||
queryset.filter(completed_at__isnull=True)
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name")
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name", "assignees__id")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)
|
||||
|
@ -165,6 +165,9 @@ class CycleViewSet(BaseViewSet):
|
||||
try:
|
||||
queryset = self.get_queryset()
|
||||
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
|
||||
if cycle_view == "all":
|
||||
@ -370,7 +373,8 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.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(
|
||||
completed_issues=Count(
|
||||
|
99
apiserver/plane/api/views/exporter.py
Normal file
99
apiserver/plane/api/views/exporter.py
Normal 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,
|
||||
)
|
@ -15,7 +15,6 @@ from sentry_sdk import capture_exception
|
||||
from .base import BaseViewSet
|
||||
from plane.api.permissions import ProjectBasePermission, ProjectLitePermission
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
Inbox,
|
||||
InboxIssue,
|
||||
Issue,
|
||||
@ -23,6 +22,7 @@ from plane.db.models import (
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
ProjectMember,
|
||||
ProjectDeployBoard,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
IssueSerializer,
|
||||
@ -377,4 +377,269 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
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,
|
||||
)
|
||||
|
||||
|
@ -48,6 +48,7 @@ from plane.api.serializers import (
|
||||
ProjectMemberLiteSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
IssueVoteSerializer,
|
||||
)
|
||||
from plane.api.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
@ -70,11 +71,12 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
ProjectDeployBoard,
|
||||
IssueVote,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.bgtasks.project_issue_export import issue_export_task
|
||||
|
||||
|
||||
class IssueViewSet(BaseViewSet):
|
||||
@ -169,7 +171,6 @@ class IssueViewSet(BaseViewSet):
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
print(filters)
|
||||
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", None]
|
||||
@ -362,6 +363,12 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
.filter(**filters)
|
||||
)
|
||||
|
||||
@ -744,21 +751,25 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
state_distribution = (
|
||||
State.objects.filter(~Q(name="Triage"), workspace__slug=slug)
|
||||
.annotate(
|
||||
state_count=Count(
|
||||
"state_issue",
|
||||
filter=Q(state_issue__parent_id=issue_id),
|
||||
)
|
||||
State.objects.filter(
|
||||
workspace__slug=slug, state_issue__parent_id=issue_id
|
||||
)
|
||||
.order_by("group")
|
||||
.values("group", "state_count")
|
||||
.annotate(state_group=F("group"))
|
||||
.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(
|
||||
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):
|
||||
permission_classes = [
|
||||
|
@ -53,6 +53,8 @@ class ModuleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
order_by = self.request.GET.get("order_by", "sort_order")
|
||||
|
||||
subquery = ModuleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
module_id=OuterRef("pk"),
|
||||
@ -106,7 +108,7 @@ class ModuleViewSet(BaseViewSet):
|
||||
filter=Q(issue_module__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
.order_by(order_by, "name")
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
@ -173,8 +175,9 @@ class ModuleViewSet(BaseViewSet):
|
||||
.annotate(first_name=F("assignees__first_name"))
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.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(
|
||||
completed_issues=Count(
|
||||
|
@ -5,7 +5,21 @@ from datetime import datetime
|
||||
# Django imports
|
||||
from django.core.exceptions import ValidationError
|
||||
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.conf import settings
|
||||
|
||||
@ -13,6 +27,7 @@ from django.conf import settings
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework import serializers
|
||||
from rest_framework.permissions import AllowAny
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
@ -23,9 +38,16 @@ from plane.api.serializers import (
|
||||
ProjectDetailSerializer,
|
||||
ProjectMemberInviteSerializer,
|
||||
ProjectFavoriteSerializer,
|
||||
IssueLiteSerializer,
|
||||
ProjectDeployBoardSerializer,
|
||||
ProjectMemberAdminSerializer,
|
||||
)
|
||||
|
||||
from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission
|
||||
from plane.api.permissions import (
|
||||
ProjectBasePermission,
|
||||
ProjectEntityPermission,
|
||||
ProjectMemberPermission,
|
||||
)
|
||||
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
@ -48,9 +70,17 @@ from plane.db.models import (
|
||||
IssueAssignee,
|
||||
ModuleMember,
|
||||
Inbox,
|
||||
ProjectDeployBoard,
|
||||
Issue,
|
||||
IssueReaction,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
Label,
|
||||
)
|
||||
|
||||
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):
|
||||
@ -109,6 +139,12 @@ class ProjectViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
member_role=ProjectMember.objects.filter(
|
||||
project_id=OuterRef("pk"),
|
||||
member_id=self.request.user.id,
|
||||
).values("role")
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@ -180,7 +216,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
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(
|
||||
project_id=serializer.data["id"],
|
||||
member_id=serializer.data["project_lead"],
|
||||
@ -451,7 +487,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
|
||||
|
||||
|
||||
class ProjectMemberViewSet(BaseViewSet):
|
||||
serializer_class = ProjectMemberSerializer
|
||||
serializer_class = ProjectMemberAdminSerializer
|
||||
model = ProjectMember
|
||||
permission_classes = [
|
||||
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):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
@ -1004,3 +1097,176 @@ class ProjectMemberEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serializer = ProjectDeployBoardSerializer(project_deploy_board)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except ProjectDeployBoard.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project Deploy Board does not exists"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ProjectDeployBoardIssuesPublicEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", None]
|
||||
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = (
|
||||
Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("project", "workspace", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
priority_order = (
|
||||
priority_order
|
||||
if order_by_param == "priority"
|
||||
else priority_order[::-1]
|
||||
)
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
When(priority=p, then=Value(i))
|
||||
for i, p in enumerate(priority_order)
|
||||
],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
# State Ordering
|
||||
elif order_by_param in [
|
||||
"state__name",
|
||||
"state__group",
|
||||
"-state__name",
|
||||
"-state__group",
|
||||
]:
|
||||
state_order = (
|
||||
state_order
|
||||
if order_by_param in ["state__name", "state__group"]
|
||||
else state_order[::-1]
|
||||
)
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
state_order=Case(
|
||||
*[
|
||||
When(state__group=state_group, then=Value(i))
|
||||
for i, state_group in enumerate(state_order)
|
||||
],
|
||||
default=Value(len(state_order)),
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("state_order")
|
||||
# assignee and label ordering
|
||||
elif order_by_param in [
|
||||
"labels__name",
|
||||
"-labels__name",
|
||||
"assignees__first_name",
|
||||
"-assignees__first_name",
|
||||
]:
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
max_values=Max(
|
||||
order_by_param[1::]
|
||||
if order_by_param.startswith("-")
|
||||
else order_by_param
|
||||
)
|
||||
).order_by(
|
||||
"-max_values" if order_by_param.startswith("-") else "max_values"
|
||||
)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
issues = IssueLiteSerializer(issue_queryset, many=True).data
|
||||
|
||||
states = State.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).values("name", "group", "color", "id")
|
||||
|
||||
labels = Label.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).values("id", "name", "color", "parent")
|
||||
|
||||
## Grouping the results
|
||||
group_by = request.GET.get("group_by", False)
|
||||
if group_by:
|
||||
issues = group_results(issues, group_by)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"issues": issues,
|
||||
"states": states,
|
||||
"labels": labels,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except ProjectDeployBoard.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
@ -19,6 +19,7 @@ from plane.db.models import (
|
||||
IssueView,
|
||||
Issue,
|
||||
IssueViewFavorite,
|
||||
IssueReaction,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
@ -77,6 +78,12 @@ class ViewIssuesEndpoint(BaseAPIView):
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
serializer = IssueLiteSerializer(issues, many=True)
|
||||
|
357
apiserver/plane/bgtasks/export_task.py
Normal file
357
apiserver/plane/bgtasks/export_task.py
Normal 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
|
38
apiserver/plane/bgtasks/exporter_expired_task.py
Normal file
38
apiserver/plane/bgtasks/exporter_expired_task.py
Normal 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)
|
@ -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
|
@ -20,6 +20,10 @@ app.conf.beat_schedule = {
|
||||
"task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues",
|
||||
"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.
|
||||
|
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
30
apiserver/plane/db/migrations/0042_auto_20230809_1745.py
Normal file
30
apiserver/plane/db/migrations/0042_auto_20230809_1745.py
Normal 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),
|
||||
]
|
38
apiserver/plane/db/migrations/0043_auto_20230809_1645.py
Normal file
38
apiserver/plane/db/migrations/0043_auto_20230809_1645.py
Normal 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),
|
||||
]
|
@ -18,6 +18,7 @@ from .project import (
|
||||
ProjectMemberInvite,
|
||||
ProjectIdentifier,
|
||||
ProjectFavorite,
|
||||
ProjectDeployBoard,
|
||||
)
|
||||
|
||||
from .issue import (
|
||||
@ -36,6 +37,7 @@ from .issue import (
|
||||
IssueSubscriber,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueVote,
|
||||
)
|
||||
|
||||
from .asset import FileAsset
|
||||
@ -72,4 +74,6 @@ from .inbox import Inbox, InboxIssue
|
||||
|
||||
from .analytic import AnalyticView
|
||||
|
||||
from .notification import Notification
|
||||
from .notification import Notification
|
||||
|
||||
from .exporter import ExporterHistory
|
@ -17,6 +17,7 @@ class Cycle(ProjectBaseModel):
|
||||
related_name="owned_by_cycle",
|
||||
)
|
||||
view_props = models.JSONField(default=dict)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Cycle"
|
||||
@ -24,6 +25,17 @@ class Cycle(ProjectBaseModel):
|
||||
db_table = "cycles"
|
||||
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):
|
||||
"""Return name of the cycle"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
56
apiserver/plane/db/models/exporter.py
Normal file
56
apiserver/plane/db/models/exporter.py
Normal 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}>"
|
@ -108,11 +108,7 @@ class Issue(ProjectBaseModel):
|
||||
~models.Q(name="Triage"), project=self.project
|
||||
).first()
|
||||
self.state = random_state
|
||||
if random_state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
else:
|
||||
if default_state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
self.state = default_state
|
||||
except ImportError:
|
||||
pass
|
||||
@ -127,8 +123,6 @@ class Issue(ProjectBaseModel):
|
||||
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
||||
completed_at=timezone.now()
|
||||
)
|
||||
elif self.state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
else:
|
||||
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
||||
completed_at=None
|
||||
@ -153,9 +147,6 @@ class Issue(ProjectBaseModel):
|
||||
if largest_sort_order is not None:
|
||||
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
|
||||
self.description_stripped = (
|
||||
None
|
||||
@ -310,6 +301,14 @@ class IssueComment(ProjectBaseModel):
|
||||
related_name="comments",
|
||||
null=True,
|
||||
)
|
||||
access = models.CharField(
|
||||
choices=(
|
||||
("INTERNAL", "INTERNAL"),
|
||||
("EXTERNAL", "EXTERNAL"),
|
||||
),
|
||||
default="INTERNAL",
|
||||
max_length=100,
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.comment_stripped = (
|
||||
@ -425,13 +424,14 @@ class IssueSubscriber(ProjectBaseModel):
|
||||
|
||||
|
||||
class IssueReaction(ProjectBaseModel):
|
||||
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
@ -446,13 +446,14 @@ class IssueReaction(ProjectBaseModel):
|
||||
|
||||
|
||||
class CommentReaction(ProjectBaseModel):
|
||||
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
@ -466,6 +467,27 @@ class CommentReaction(ProjectBaseModel):
|
||||
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
|
||||
@receiver(post_save, sender=Issue)
|
||||
|
@ -40,6 +40,7 @@ class Module(ProjectBaseModel):
|
||||
through_fields=("module", "member"),
|
||||
)
|
||||
view_props = models.JSONField(default=dict)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "project"]
|
||||
@ -48,6 +49,17 @@ class Module(ProjectBaseModel):
|
||||
db_table = "modules"
|
||||
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):
|
||||
return f"{self.name} {self.start_date} {self.target_date}"
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
# Python imports
|
||||
from uuid import uuid4
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
@ -31,12 +34,9 @@ def get_default_props():
|
||||
"showEmptyGroups": True,
|
||||
}
|
||||
|
||||
|
||||
def get_default_preferences():
|
||||
return {
|
||||
"pages": {
|
||||
"block_display": True
|
||||
}
|
||||
}
|
||||
return {"pages": {"block_display": True}}
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
@ -157,7 +157,6 @@ class ProjectMember(ProjectBaseModel):
|
||||
preferences = models.JSONField(default=get_default_preferences)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
smallest_sort_order = ProjectMember.objects.filter(
|
||||
@ -217,3 +216,41 @@ class ProjectFavorite(ProjectBaseModel):
|
||||
def __str__(self):
|
||||
"""Return user of the project"""
|
||||
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}>"
|
||||
|
@ -33,6 +33,7 @@ def get_default_props():
|
||||
"estimate": True,
|
||||
"created_on": True,
|
||||
"updated_on": True,
|
||||
"start_date": True,
|
||||
},
|
||||
"showEmptyGroups": True,
|
||||
}
|
||||
|
@ -214,4 +214,4 @@ SIMPLE_JWT = {
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
CELERY_TASK_SERIALIZER = '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")
|
||||
|
@ -124,10 +124,11 @@ def filter_created_at(params, filter, method):
|
||||
else:
|
||||
if params.get("created_at", None) and len(params.get("created_at")):
|
||||
for query in params.get("created_at"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["created_at__date__gte"] = query.get("datetime")
|
||||
created_at_query = query.split(";")
|
||||
if len(created_at_query) == 2 and "after" in created_at_query:
|
||||
filter["created_at__date__gte"] = created_at_query[0]
|
||||
else:
|
||||
filter["created_at__date__lte"] = query.get("datetime")
|
||||
filter["created_at__date__lte"] = created_at_query[0]
|
||||
return filter
|
||||
|
||||
|
||||
@ -144,10 +145,11 @@ def filter_updated_at(params, filter, method):
|
||||
else:
|
||||
if params.get("updated_at", None) and len(params.get("updated_at")):
|
||||
for query in params.get("updated_at"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["updated_at__date__gte"] = query.get("datetime")
|
||||
updated_at_query = query.split(";")
|
||||
if len(updated_at_query) == 2 and "after" in updated_at_query:
|
||||
filter["updated_at__date__gte"] = updated_at_query[0]
|
||||
else:
|
||||
filter["updated_at__date__lte"] = query.get("datetime")
|
||||
filter["updated_at__date__lte"] = updated_at_query[0]
|
||||
return filter
|
||||
|
||||
|
||||
@ -164,10 +166,11 @@ def filter_start_date(params, filter, method):
|
||||
else:
|
||||
if params.get("start_date", None) and len(params.get("start_date")):
|
||||
for query in params.get("start_date"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["start_date__gte"] = query.get("datetime")
|
||||
start_date_query = query.split(";")
|
||||
if len(start_date_query) == 2 and "after" in start_date_query:
|
||||
filter["start_date__gte"] = start_date_query[0]
|
||||
else:
|
||||
filter["start_date__lte"] = query.get("datetime")
|
||||
filter["start_date__lte"] = start_date_query[0]
|
||||
return filter
|
||||
|
||||
|
||||
@ -184,10 +187,11 @@ def filter_target_date(params, filter, method):
|
||||
else:
|
||||
if params.get("target_date", None) and len(params.get("target_date")):
|
||||
for query in params.get("target_date"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["target_date__gt"] = query.get("datetime")
|
||||
target_date_query = query.split(";")
|
||||
if len(target_date_query) == 2 and "after" in target_date_query:
|
||||
filter["target_date__gt"] = target_date_query[0]
|
||||
else:
|
||||
filter["target_date__lt"] = query.get("datetime")
|
||||
filter["target_date__lt"] = target_date_query[0]
|
||||
|
||||
return filter
|
||||
|
||||
@ -205,10 +209,11 @@ def filter_completed_at(params, filter, method):
|
||||
else:
|
||||
if params.get("completed_at", None) and len(params.get("completed_at")):
|
||||
for query in params.get("completed_at"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["completed_at__date__gte"] = query.get("datetime")
|
||||
completed_at_query = query.split(";")
|
||||
if len(completed_at_query) == 2 and "after" in completed_at_query:
|
||||
filter["completed_at__date__gte"] = completed_at_query[0]
|
||||
else:
|
||||
filter["completed_at__lte"] = query.get("datetime")
|
||||
filter["completed_at__lte"] = completed_at_query[0]
|
||||
return filter
|
||||
|
||||
|
||||
@ -292,9 +297,16 @@ def filter_subscribed_issues(params, filter, method):
|
||||
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):
|
||||
filter = dict()
|
||||
print(query_params)
|
||||
|
||||
ISSUE_FILTER = {
|
||||
"state": filter_state,
|
||||
@ -318,6 +330,7 @@ def issue_filters(query_params, method):
|
||||
"inbox_status": filter_inbox_status,
|
||||
"sub_issue": filter_sub_issue_toggle,
|
||||
"subscriber": filter_subscribed_issues,
|
||||
"start_target_date": filter_start_target_date_issues,
|
||||
}
|
||||
|
||||
for key, value in ISSUE_FILTER.items():
|
||||
|
@ -32,4 +32,5 @@ celery==5.3.1
|
||||
django_celery_beat==2.5.0
|
||||
psycopg-binary==3.1.9
|
||||
psycopg-c==3.1.9
|
||||
scout-apm==2.26.1
|
||||
scout-apm==2.26.1
|
||||
openpyxl==3.1.2
|
@ -5,18 +5,23 @@ type Props = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
count: number;
|
||||
id: 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]">
|
||||
<h6 className="text-base font-medium">{title}</h6>
|
||||
{users.length > 0 ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
<a
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -38,7 +43,7 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex-shrink-0">{user.count}</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
@ -60,8 +60,10 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
|
||||
lastName: user?.created_by__last_name,
|
||||
display_name: user?.created_by__display_name,
|
||||
count: user?.count,
|
||||
id: user?.created_by__id,
|
||||
}))}
|
||||
title="Most issues created"
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
/>
|
||||
<AnalyticsLeaderboard
|
||||
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,
|
||||
display_name: user?.assignees__display_name,
|
||||
count: user?.count,
|
||||
id: user?.assignees__id,
|
||||
}))}
|
||||
title="Most issues closed"
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
/>
|
||||
<div className={fullScreen ? "md:col-span-2" : ""}>
|
||||
<AnalyticsYearWiseIssues defaultAnalytics={defaultAnalytics} />
|
||||
|
@ -7,6 +7,8 @@ import { useTheme } from "next-themes";
|
||||
import { SettingIcon } from "components/icons";
|
||||
import userService from "services/user.service";
|
||||
import useUser from "hooks/use-user";
|
||||
// helper
|
||||
import { unsetCustomCssVariables } from "helpers/theme.helper";
|
||||
|
||||
type Props = {
|
||||
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||
@ -22,6 +24,8 @@ export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
|
||||
const updateUserTheme = (newTheme: string) => {
|
||||
if (!user) return;
|
||||
|
||||
unsetCustomCssVariables();
|
||||
|
||||
setTheme(newTheme);
|
||||
|
||||
mutateUser((prevData) => {
|
||||
|
@ -354,8 +354,8 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Item
|
||||
key={item.id}
|
||||
onSelect={() => {
|
||||
router.push(currentSection.path(item));
|
||||
setIsPaletteOpen(false);
|
||||
router.push(currentSection.path(item));
|
||||
}}
|
||||
value={`${key}-${item?.name}`}
|
||||
className="focus:outline-none"
|
||||
@ -379,6 +379,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Group heading="Issue actions">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
setPlaceholder("Change state...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-state"]);
|
||||
@ -460,6 +461,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Group heading="Issue">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "c",
|
||||
});
|
||||
@ -479,6 +481,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Group heading="Project">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "p",
|
||||
});
|
||||
@ -500,6 +503,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Group heading="Cycle">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "q",
|
||||
});
|
||||
@ -517,6 +521,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Group heading="Module">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "m",
|
||||
});
|
||||
@ -534,6 +539,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Group heading="View">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "v",
|
||||
});
|
||||
@ -551,6 +557,7 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Group heading="Page">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "d",
|
||||
});
|
||||
@ -568,11 +575,12 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
{projectDetails && projectDetails.inbox_view && (
|
||||
<Command.Group heading="Inbox">
|
||||
<Command.Item
|
||||
onSelect={() =>
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
redirect(
|
||||
`/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}`
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
|
@ -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: {
|
||||
[key: string]: {
|
||||
message: (activity: IIssueActivity, showIssue: boolean) => React.ReactNode;
|
||||
@ -46,8 +62,7 @@ const activityDetails: {
|
||||
if (activity.old_value === "")
|
||||
return (
|
||||
<>
|
||||
added a new assignee{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
added a new assignee <UserLink activity={activity} />
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
@ -60,8 +75,7 @@ const activityDetails: {
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed the assignee{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
|
||||
removed the assignee <UserLink activity={activity} />
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
|
@ -113,49 +113,51 @@ export const IssuesFilterView: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<SelectFilters
|
||||
filters={filters}
|
||||
onSelect={(option) => {
|
||||
const key = option.key as keyof typeof filters;
|
||||
{issueView !== "gantt_chart" && (
|
||||
<SelectFilters
|
||||
filters={filters}
|
||||
onSelect={(option) => {
|
||||
const key = option.key as keyof typeof filters;
|
||||
|
||||
if (key === "target_date") {
|
||||
const valueExists = checkIfArraysHaveSameElements(
|
||||
filters.target_date ?? [],
|
||||
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)
|
||||
if (key === "target_date") {
|
||||
const valueExists = checkIfArraysHaveSameElements(
|
||||
filters.target_date ?? [],
|
||||
option.value
|
||||
);
|
||||
else
|
||||
setFilters(
|
||||
{
|
||||
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
|
||||
},
|
||||
!Boolean(viewId)
|
||||
);
|
||||
}
|
||||
}}
|
||||
direction="left"
|
||||
height="rg"
|
||||
/>
|
||||
|
||||
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(
|
||||
{
|
||||
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
|
||||
},
|
||||
!Boolean(viewId)
|
||||
);
|
||||
}
|
||||
}}
|
||||
direction="left"
|
||||
height="rg"
|
||||
/>
|
||||
)}
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<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
|
||||
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
|
||||
: "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">
|
||||
<div className="relative divide-y-2 divide-custom-border-200">
|
||||
<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">
|
||||
<h4 className="text-custom-text-200">Group by</h4>
|
||||
<div className="w-28">
|
||||
@ -206,34 +209,34 @@ export const IssuesFilterView: React.FC = () => {
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Order by</h4>
|
||||
<div className="w-28">
|
||||
<CustomMenu
|
||||
label={
|
||||
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
|
||||
"Select"
|
||||
}
|
||||
className="!w-full"
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
{ORDER_BY_OPTIONS.map((option) =>
|
||||
groupByProperty === "priority" &&
|
||||
option.key === "priority" ? null : (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
setOrderBy(option.key);
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
)
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
)}
|
||||
{issueView !== "calendar" && issueView !== "spreadsheet" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Order by</h4>
|
||||
<div className="w-28">
|
||||
<CustomMenu
|
||||
label={
|
||||
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
|
||||
"Select"
|
||||
}
|
||||
className="!w-full"
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
{ORDER_BY_OPTIONS.map((option) =>
|
||||
groupByProperty === "priority" && option.key === "priority" ? null : (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
setOrderBy(option.key);
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
)
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Issue type</h4>
|
||||
@ -263,16 +266,19 @@ export const IssuesFilterView: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{issueView !== "calendar" && issueView !== "spreadsheet" && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Show sub-issues</h4>
|
||||
<div className="w-28">
|
||||
<ToggleSwitch
|
||||
value={showSubIssues}
|
||||
onChange={() => setShowSubIssues(!showSubIssues)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Show sub-issues</h4>
|
||||
<div className="w-28">
|
||||
<ToggleSwitch
|
||||
value={showSubIssues}
|
||||
onChange={() => setShowSubIssues(!showSubIssues)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{issueView !== "calendar" &&
|
||||
issueView !== "spreadsheet" &&
|
||||
issueView !== "gantt_chart" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Show empty states</h4>
|
||||
<div className="w-28">
|
||||
@ -282,6 +288,10 @@ export const IssuesFilterView: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{issueView !== "calendar" &&
|
||||
issueView !== "spreadsheet" &&
|
||||
issueView !== "gantt_chart" && (
|
||||
<div className="relative flex justify-end gap-x-3">
|
||||
<button type="button" onClick={() => resetFilterToDefault()}>
|
||||
Reset to default
|
||||
@ -294,47 +304,48 @@ export const IssuesFilterView: React.FC = () => {
|
||||
Set as default
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 py-3">
|
||||
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
|
||||
<div className="flex flex-wrap items-center gap-2 text-custom-text-200">
|
||||
{Object.keys(properties).map((key) => {
|
||||
if (key === "estimate" && !isEstimateActive) return null;
|
||||
{issueView !== "gantt_chart" && (
|
||||
<div className="space-y-2 py-3">
|
||||
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
|
||||
<div className="flex flex-wrap items-center gap-2 text-custom-text-200">
|
||||
{Object.keys(properties).map((key) => {
|
||||
if (key === "estimate" && !isEstimateActive) return null;
|
||||
|
||||
if (
|
||||
issueView === "spreadsheet" &&
|
||||
(key === "attachment_count" ||
|
||||
key === "link" ||
|
||||
key === "sub_issue_count")
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
issueView === "spreadsheet" &&
|
||||
(key === "attachment_count" ||
|
||||
key === "link" ||
|
||||
key === "sub_issue_count")
|
||||
)
|
||||
return null;
|
||||
|
||||
if (
|
||||
issueView !== "spreadsheet" &&
|
||||
(key === "created_on" || key === "updated_on")
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
issueView !== "spreadsheet" &&
|
||||
(key === "created_on" || key === "updated_on")
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||
properties[key as keyof Properties]
|
||||
? "border-custom-primary bg-custom-primary text-white"
|
||||
: "border-custom-border-200"
|
||||
}`}
|
||||
onClick={() => setProperties(key as keyof Properties)}
|
||||
>
|
||||
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||
properties[key as keyof Properties]
|
||||
? "border-custom-primary bg-custom-primary text-white"
|
||||
: "border-custom-border-200"
|
||||
}`}
|
||||
onClick={() => setProperties(key as keyof Properties)}
|
||||
>
|
||||
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
|
@ -27,8 +27,8 @@ const unsplashEnabled =
|
||||
|
||||
const tabOptions = [
|
||||
{
|
||||
key: "unsplash",
|
||||
title: "Unsplash",
|
||||
key: "images",
|
||||
title: "Images",
|
||||
},
|
||||
{
|
||||
key: "upload",
|
||||
|
@ -21,13 +21,21 @@ import { ICustomTheme } from "types";
|
||||
|
||||
type Props = {
|
||||
name: keyof ICustomTheme;
|
||||
position?: "left" | "right";
|
||||
watch: UseFormWatch<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
error: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined;
|
||||
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 { hex } = newColor;
|
||||
setValue(name, hex);
|
||||
@ -104,7 +112,11 @@ export const ColorPickerInput: React.FC<Props> = ({ name, watch, setValue, error
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
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} />
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
|
@ -83,6 +83,7 @@ export const CustomThemeSelector: React.FC<Props> = observer(({ preLoadedData })
|
||||
</h3>
|
||||
<ColorPickerInput
|
||||
name="background"
|
||||
position="right"
|
||||
error={errors.background}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
@ -120,6 +121,7 @@ export const CustomThemeSelector: React.FC<Props> = observer(({ preLoadedData })
|
||||
</h3>
|
||||
<ColorPickerInput
|
||||
name="sidebarBackground"
|
||||
position="right"
|
||||
error={errors.sidebarBackground}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
|
@ -10,7 +10,7 @@ import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useProjects from "hooks/use-projects";
|
||||
// component
|
||||
import { Avatar } from "components/ui";
|
||||
import { Avatar, Icon } from "components/ui";
|
||||
// icons
|
||||
import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
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 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" : ""
|
||||
}`}
|
||||
>
|
||||
@ -155,11 +155,7 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
>
|
||||
{getGroupTitle()}
|
||||
</h2>
|
||||
<span
|
||||
className={`${
|
||||
isCollapsed ? "ml-0.5" : ""
|
||||
} min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs`}
|
||||
>
|
||||
<span className={`${isCollapsed ? "ml-0.5" : ""} py-1 text-center text-sm`}>
|
||||
{groupedIssues?.[groupTitle].length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
@ -174,9 +170,12 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
{!disableUserActions && selectedGroup !== "created_by" && (
|
||||
|
@ -232,7 +232,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
</a>
|
||||
</ContextMenu>
|
||||
<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" : ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
@ -301,10 +301,10 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</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>
|
||||
</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 && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
@ -347,6 +347,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
customButton
|
||||
user={user}
|
||||
selfPositioned
|
||||
/>
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
ViewEstimateSelect,
|
||||
ViewIssueLabel,
|
||||
ViewPrioritySelect,
|
||||
ViewStartDateSelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues";
|
||||
import { Popover2 } from "@blueprintjs/popover2";
|
||||
@ -315,6 +316,19 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
|
||||
</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 && (
|
||||
<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
|
||||
|
@ -1,21 +1,28 @@
|
||||
import { FC } from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { KeyedMutator } from "swr";
|
||||
|
||||
// services
|
||||
import cyclesService from "services/cycles.service";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// components
|
||||
import { GanttChartRoot } from "components/gantt-chart";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
import { CycleGanttBlock, GanttChartRoot, IBlockUpdateData } from "components/gantt-chart";
|
||||
// types
|
||||
import { ICycle } from "types";
|
||||
|
||||
type Props = {
|
||||
cycles: ICycle[];
|
||||
mutateCycles: KeyedMutator<ICycle[]>;
|
||||
};
|
||||
|
||||
export const CyclesListGanttChartView: FC<Props> = ({ cycles }) => {
|
||||
export const CyclesListGanttChartView: FC<Props> = ({ cycles, mutateCycles }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
// rendering issues on gantt sidebar
|
||||
const GanttSidebarBlockView = ({ data }: any) => (
|
||||
@ -28,53 +35,65 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles }) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// rendering issues on gantt card
|
||||
const GanttBlockView = ({ data }: { data: ICycle }) => (
|
||||
<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>
|
||||
);
|
||||
const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => {
|
||||
if (!workspaceSlug || !user) return;
|
||||
|
||||
// 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,
|
||||
};
|
||||
mutateCycles((prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const newList = prevData.map((p) => ({
|
||||
...p,
|
||||
...(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.map((_block: any) => {
|
||||
if (_block?.start_date && _block.target_date) console.log("_block", _block);
|
||||
return {
|
||||
start_date: new Date(_block.created_at),
|
||||
target_date: new Date(_block.updated_at),
|
||||
data: _block,
|
||||
};
|
||||
})
|
||||
? blocks
|
||||
.filter((b) => b.start_date && b.end_date)
|
||||
.map((block) => ({
|
||||
data: block,
|
||||
id: block.id,
|
||||
sort_order: block.sort_order,
|
||||
start_date: new Date(block.start_date ?? ""),
|
||||
target_date: new Date(block.end_date ?? ""),
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-y-auto">
|
||||
<GanttChartRoot
|
||||
title={"Cycles"}
|
||||
title="Cycles"
|
||||
loaderTitle="Cycles"
|
||||
blocks={cycles ? blockFormat(cycles) : null}
|
||||
blockUpdateHandler={handleUpdateDates}
|
||||
blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)}
|
||||
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>
|
||||
);
|
||||
|
@ -17,7 +17,7 @@ export const AllCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: allCyclesList } = useSWR(
|
||||
const { data: allCyclesList, mutate } = useSWR(
|
||||
workspaceSlug && projectId ? CYCLES_LIST(projectId.toString()) : null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
@ -25,5 +25,5 @@ export const AllCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
: null
|
||||
);
|
||||
|
||||
return <CyclesView cycles={allCyclesList} viewType={viewType} />;
|
||||
return <CyclesView cycles={allCyclesList} mutateCycles={mutate} viewType={viewType} />;
|
||||
};
|
||||
|
@ -17,7 +17,7 @@ export const CompletedCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: completedCyclesList } = useSWR(
|
||||
const { data: completedCyclesList, mutate } = useSWR(
|
||||
workspaceSlug && projectId ? COMPLETED_CYCLES_LIST(projectId.toString()) : null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
@ -29,5 +29,5 @@ export const CompletedCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
: null
|
||||
);
|
||||
|
||||
return <CyclesView cycles={completedCyclesList} viewType={viewType} />;
|
||||
return <CyclesView cycles={completedCyclesList} mutateCycles={mutate} viewType={viewType} />;
|
||||
};
|
||||
|
@ -17,7 +17,7 @@ export const DraftCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: draftCyclesList } = useSWR(
|
||||
const { data: draftCyclesList, mutate } = useSWR(
|
||||
workspaceSlug && projectId ? DRAFT_CYCLES_LIST(projectId.toString()) : null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
@ -25,5 +25,5 @@ export const DraftCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
: null
|
||||
);
|
||||
|
||||
return <CyclesView cycles={draftCyclesList} viewType={viewType} />;
|
||||
return <CyclesView cycles={draftCyclesList} mutateCycles={mutate} viewType={viewType} />;
|
||||
};
|
||||
|
@ -17,7 +17,7 @@ export const UpcomingCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: upcomingCyclesList } = useSWR(
|
||||
const { data: upcomingCyclesList, mutate } = useSWR(
|
||||
workspaceSlug && projectId ? UPCOMING_CYCLES_LIST(projectId.toString()) : null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
@ -29,5 +29,5 @@ export const UpcomingCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
: null
|
||||
);
|
||||
|
||||
return <CyclesView cycles={upcomingCyclesList} viewType={viewType} />;
|
||||
return <CyclesView cycles={upcomingCyclesList} mutateCycles={mutate} viewType={viewType} />;
|
||||
};
|
||||
|
@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
import { KeyedMutator, mutate } from "swr";
|
||||
|
||||
// services
|
||||
import cyclesService from "services/cycles.service";
|
||||
@ -35,10 +35,11 @@ import {
|
||||
|
||||
type Props = {
|
||||
cycles: ICycle[] | undefined;
|
||||
mutateCycles: KeyedMutator<ICycle[]>;
|
||||
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 [selectedCycleToUpdate, setSelectedCycleToUpdate] = useState<ICycle | null>(null);
|
||||
|
||||
@ -202,7 +203,7 @@ export const CyclesView: React.FC<Props> = ({ cycles, viewType }) => {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<CyclesListGanttChartView cycles={cycles ?? []} />
|
||||
<CyclesListGanttChartView cycles={cycles ?? []} mutateCycles={mutateCycles} />
|
||||
)
|
||||
) : (
|
||||
<div className="h-full grid place-items-center text-center">
|
||||
|
@ -1,20 +1,27 @@
|
||||
import { FC } from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { GanttChartRoot } from "components/gantt-chart";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
|
||||
// hooks
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useUser from "hooks/use-user";
|
||||
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: FC<Props> = ({}) => {
|
||||
export const CycleIssuesGanttChartView = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||
|
||||
const { orderBy } = useIssuesView();
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { ganttIssues, mutateGanttIssues } = useGanttChartCycleIssues(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
@ -32,77 +39,18 @@ export const CycleIssuesGanttChartView: FC<Props> = ({}) => {
|
||||
</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 (
|
||||
<div className="w-full h-full p-3">
|
||||
<GanttChartRoot
|
||||
title="Cycles"
|
||||
loaderTitle="Cycles"
|
||||
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
|
||||
blockUpdateHandler={handleUpdateDates}
|
||||
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
|
||||
blockUpdateHandler={(block, payload) =>
|
||||
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
|
||||
}
|
||||
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>
|
||||
);
|
||||
|
103
apps/app/components/gantt-chart/blocks/block.tsx
Normal file
103
apps/app/components/gantt-chart/blocks/block.tsx
Normal 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>
|
||||
);
|
||||
};
|
178
apps/app/components/gantt-chart/blocks/blocks-display.tsx
Normal file
178
apps/app/components/gantt-chart/blocks/blocks-display.tsx
Normal 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>
|
||||
);
|
||||
};
|
2
apps/app/components/gantt-chart/blocks/index.ts
Normal file
2
apps/app/components/gantt-chart/blocks/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./block";
|
||||
export * from "./blocks-display";
|
@ -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>
|
||||
);
|
||||
};
|
@ -25,7 +25,7 @@ export const BiWeekChartView: FC<any> = () => {
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData.data.width}px` }}
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
|
||||
|
@ -25,7 +25,7 @@ export const DayChartView: FC<any> = () => {
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData.data.width}px` }}
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
|
||||
|
@ -25,7 +25,7 @@ export const HourChartView: FC<any> = () => {
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData.data.width}px` }}
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
|
||||
|
@ -1,13 +1,8 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
// icons
|
||||
import {
|
||||
Bars4Icon,
|
||||
XMarkIcon,
|
||||
ArrowsPointingInIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid";
|
||||
// components
|
||||
import { GanttChartBlocks } from "../blocks";
|
||||
import { GanttChartBlocks } from "components/gantt-chart";
|
||||
// import { HourChartView } from "./hours";
|
||||
// import { DayChartView } from "./day";
|
||||
// import { WeekChartView } from "./week";
|
||||
@ -30,9 +25,9 @@ import {
|
||||
getMonthChartItemPositionWidthInMonth,
|
||||
} from "../views";
|
||||
// types
|
||||
import { ChartDataType } from "../types";
|
||||
import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types";
|
||||
// data
|
||||
import { datePreview, currentViewDataWithView } from "../data";
|
||||
import { currentViewDataWithView } from "../data";
|
||||
// context
|
||||
import { useChart } from "../hooks";
|
||||
|
||||
@ -40,10 +35,13 @@ type ChartViewRootProps = {
|
||||
border: boolean;
|
||||
title: null | string;
|
||||
loaderTitle: string;
|
||||
blocks: any;
|
||||
blockUpdateHandler: (data: any) => void;
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
sidebarBlockRender: FC<any>;
|
||||
blockRender: FC<any>;
|
||||
enableLeftDrag: boolean;
|
||||
enableRightDrag: boolean;
|
||||
enableReorder: boolean;
|
||||
};
|
||||
|
||||
export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
@ -54,6 +52,9 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
blockUpdateHandler,
|
||||
sidebarBlockRender,
|
||||
blockRender,
|
||||
enableLeftDrag,
|
||||
enableRightDrag,
|
||||
enableReorder,
|
||||
}) => {
|
||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
||||
|
||||
@ -62,13 +63,13 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
const [blocksSidebarView, setBlocksSidebarView] = useState<boolean>(false);
|
||||
|
||||
// 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.map((_block: any) => ({
|
||||
..._block,
|
||||
position: getMonthChartItemPositionWidthInMonth(view, _block),
|
||||
? blocks.map((block: any) => ({
|
||||
...block,
|
||||
position: getMonthChartItemPositionWidthInMonth(view, block),
|
||||
}))
|
||||
: [];
|
||||
|
||||
@ -154,13 +155,14 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
|
||||
const updatingCurrentLeftScrollPosition = (width: number) => {
|
||||
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
|
||||
scrollContainer.scrollLeft = width + scrollContainer.scrollLeft;
|
||||
setItemsContainerWidth(width + scrollContainer.scrollLeft);
|
||||
scrollContainer.scrollLeft = width + scrollContainer?.scrollLeft;
|
||||
setItemsContainerWidth(width + scrollContainer?.scrollLeft);
|
||||
};
|
||||
|
||||
const handleScrollToCurrentSelectedDate = (currentState: ChartDataType, date: Date) => {
|
||||
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
|
||||
const clientVisibleWidth: number = scrollContainer.clientWidth;
|
||||
|
||||
const clientVisibleWidth: number = scrollContainer?.clientWidth;
|
||||
let scrollWidth: number = 0;
|
||||
let daysDifference: number = 0;
|
||||
|
||||
@ -189,9 +191,9 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
const onScroll = () => {
|
||||
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
|
||||
|
||||
const scrollWidth: number = scrollContainer.scrollWidth;
|
||||
const clientVisibleWidth: number = scrollContainer.clientWidth;
|
||||
const currentScrollPosition: number = scrollContainer.scrollLeft;
|
||||
const scrollWidth: number = scrollContainer?.scrollWidth;
|
||||
const clientVisibleWidth: number = scrollContainer?.clientWidth;
|
||||
const currentScrollPosition: number = scrollContainer?.scrollLeft;
|
||||
|
||||
const approxRangeLeft: number =
|
||||
scrollWidth >= clientVisibleWidth + 1000 ? 1000 : scrollWidth - clientVisibleWidth;
|
||||
@ -207,6 +209,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
|
||||
|
||||
scrollContainer.addEventListener("scroll", onScroll);
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
@ -242,7 +245,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
</div> */}
|
||||
|
||||
{/* 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
|
||||
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)}
|
||||
@ -301,8 +304,8 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
</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"
|
||||
onClick={() => setFullScreenMode(() => !fullScreenMode)}
|
||||
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((prevData) => !prevData)}
|
||||
>
|
||||
{fullScreenMode ? (
|
||||
<ArrowsPointingInIcon className="h-4 w-4" />
|
||||
@ -325,6 +328,10 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
blocks={chartBlocks}
|
||||
sidebarBlockRender={sidebarBlockRender}
|
||||
blockRender={blockRender}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableLeftDrag={enableLeftDrag}
|
||||
enableRightDrag={enableRightDrag}
|
||||
enableReorder={enableReorder}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -1,48 +1,55 @@
|
||||
import { FC } from "react";
|
||||
// context
|
||||
|
||||
// hooks
|
||||
import { useChart } from "../hooks";
|
||||
// types
|
||||
import { IMonthBlock } from "../views";
|
||||
|
||||
export const MonthChartView: FC<any> = () => {
|
||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
||||
const { currentViewData, renderView } = useChart();
|
||||
|
||||
const monthBlocks: IMonthBlock[] = renderView;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute flex h-full flex-grow divide-x divide-custom-border-200">
|
||||
{renderView &&
|
||||
renderView.length > 0 &&
|
||||
renderView.map((_itemRoot: any, _idxRoot: any) => (
|
||||
<div key={`title-${_idxRoot}`} className="relative flex flex-col">
|
||||
<div className="absolute flex h-full flex-grow divide-x divide-custom-border-100/50">
|
||||
{monthBlocks &&
|
||||
monthBlocks.length > 0 &&
|
||||
monthBlocks.map((block, _idxRoot) => (
|
||||
<div key={`month-${block?.month}-${block?.year}`} className="relative flex flex-col">
|
||||
<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">
|
||||
{_itemRoot?.title}
|
||||
{block?.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full w-full divide-x divide-custom-border-200">
|
||||
{_itemRoot.children &&
|
||||
_itemRoot.children.length > 0 &&
|
||||
_itemRoot.children.map((_item: any, _idx: any) => (
|
||||
<div className="flex h-full w-full divide-x divide-custom-border-100/50">
|
||||
{block?.children &&
|
||||
block?.children.length > 0 &&
|
||||
block?.children.map((monthDay, _idx) => (
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData.data.width}px` }}
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
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
|
||||
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`
|
||||
: ``
|
||||
}`}
|
||||
>
|
||||
{_item?.today && (
|
||||
<div className="absolute top-0 bottom-0 border border-red-500"> </div>
|
||||
{monthDay?.today && (
|
||||
<div className="absolute top-0 bottom-0 w-[1px] bg-red-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -25,7 +25,7 @@ export const QuarterChartView: FC<any> = () => {
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData.data.width}px` }}
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
|
||||
|
@ -25,7 +25,7 @@ export const WeekChartView: FC<any> = () => {
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData.data.width}px` }}
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
|
||||
|
@ -25,7 +25,7 @@ export const YearChartView: FC<any> = () => {
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData.data.width}px` }}
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
|
||||
|
14
apps/app/components/gantt-chart/helpers/block-structure.tsx
Normal file
14
apps/app/components/gantt-chart/helpers/block-structure.tsx
Normal 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 ?? ""),
|
||||
}))
|
||||
: [];
|
@ -1,138 +1,155 @@
|
||||
import { useState, useRef } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
export const ChartDraggable = ({ children, block, handleBlock, className }: any) => {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
// react-beautiful-dnd
|
||||
import { DraggableProvided } from "react-beautiful-dnd";
|
||||
import { useChart } from "../hooks";
|
||||
// types
|
||||
import { IGanttBlock } from "../types";
|
||||
|
||||
const [chartBlockPositionLeft, setChartBlockPositionLeft] = useState(0);
|
||||
const [blockPositionLeft, setBlockPositionLeft] = useState(0);
|
||||
const [dragBlockOffsetX, setDragBlockOffsetX] = useState(0);
|
||||
type Props = {
|
||||
children: any;
|
||||
block: IGanttBlock;
|
||||
handleBlock: (totalBlockShifts: number, dragDirection: "left" | "right") => void;
|
||||
enableLeftDrag: boolean;
|
||||
enableRightDrag: boolean;
|
||||
provided: DraggableProvided;
|
||||
};
|
||||
|
||||
const handleMouseDown = (event: any) => {
|
||||
const chartBlockPositionLeft: number = block.position.marginLeft;
|
||||
const blockPositionLeft: number = event.target.getBoundingClientRect().left;
|
||||
const dragBlockOffsetX: number = event.clientX - event.target.getBoundingClientRect().left;
|
||||
export const ChartDraggable: React.FC<Props> = ({
|
||||
children,
|
||||
block,
|
||||
handleBlock,
|
||||
enableLeftDrag = true,
|
||||
enableRightDrag = true,
|
||||
provided,
|
||||
}) => {
|
||||
const [isLeftResizing, setIsLeftResizing] = useState(false);
|
||||
const [isRightResizing, setIsRightResizing] = useState(false);
|
||||
|
||||
console.log("--------------------");
|
||||
console.log("chartBlockPositionLeft", chartBlockPositionLeft);
|
||||
console.log("blockPositionLeft", blockPositionLeft);
|
||||
console.log("dragBlockOffsetX", dragBlockOffsetX);
|
||||
console.log("-->");
|
||||
const parentDivRef = useRef<HTMLDivElement>(null);
|
||||
const resizableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
setDragging(true);
|
||||
setChartBlockPositionLeft(chartBlockPositionLeft);
|
||||
setBlockPositionLeft(blockPositionLeft);
|
||||
setDragBlockOffsetX(dragBlockOffsetX);
|
||||
};
|
||||
const { currentViewData } = useChart();
|
||||
|
||||
const handleMouseMove = (event: any) => {
|
||||
if (!dragging) return;
|
||||
const handleDrag = (dragDirection: "left" | "right") => {
|
||||
if (!currentViewData || !resizableRef.current || !parentDivRef.current || !block.position)
|
||||
return;
|
||||
|
||||
const currentBlockPosition = event.clientX - dragBlockOffsetX;
|
||||
console.log("currentBlockPosition", currentBlockPosition);
|
||||
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 resizableDiv = resizableRef.current;
|
||||
const parentDiv = parentDivRef.current;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDragging(false);
|
||||
setChartBlockPositionLeft(0);
|
||||
setBlockPositionLeft(0);
|
||||
setDragBlockOffsetX(0);
|
||||
const columnWidth = currentViewData.data.width;
|
||||
|
||||
const blockInitialWidth =
|
||||
resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
|
||||
|
||||
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 (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
className={`${className ? className : ``}`}
|
||||
id={`block-${block.id}`}
|
||||
ref={parentDivRef}
|
||||
className="relative group inline-flex cursor-pointer items-center font-medium transition-all"
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
// 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>
|
||||
// );
|
||||
// };
|
||||
|
1
apps/app/components/gantt-chart/helpers/index.ts
Normal file
1
apps/app/components/gantt-chart/helpers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./block-structure";
|
43
apps/app/components/gantt-chart/hooks/block-update.tsx
Normal file
43
apps/app/components/gantt-chart/hooks/block-update.tsx
Normal 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());
|
||||
};
|
@ -1 +1,5 @@
|
||||
export * from "./blocks";
|
||||
export * from "./helpers";
|
||||
export * from "./hooks";
|
||||
export * from "./root";
|
||||
export * from "./types";
|
||||
|
@ -3,15 +3,20 @@ import { FC } from "react";
|
||||
import { ChartViewRoot } from "./chart";
|
||||
// context
|
||||
import { ChartContextProvider } from "./contexts";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "./types";
|
||||
|
||||
type GanttChartRootProps = {
|
||||
border?: boolean;
|
||||
title: null | string;
|
||||
loaderTitle: string;
|
||||
blocks: any;
|
||||
blockUpdateHandler: (data: any) => void;
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
sidebarBlockRender: FC<any>;
|
||||
blockRender: FC<any>;
|
||||
enableLeftDrag?: boolean;
|
||||
enableRightDrag?: boolean;
|
||||
enableReorder?: boolean;
|
||||
};
|
||||
|
||||
export const GanttChartRoot: FC<GanttChartRootProps> = ({
|
||||
@ -22,6 +27,9 @@ export const GanttChartRoot: FC<GanttChartRootProps> = ({
|
||||
blockUpdateHandler,
|
||||
sidebarBlockRender,
|
||||
blockRender,
|
||||
enableLeftDrag = true,
|
||||
enableRightDrag = true,
|
||||
enableReorder = true,
|
||||
}) => (
|
||||
<ChartContextProvider>
|
||||
<ChartViewRoot
|
||||
@ -32,6 +40,9 @@ export const GanttChartRoot: FC<GanttChartRootProps> = ({
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
sidebarBlockRender={sidebarBlockRender}
|
||||
blockRender={blockRender}
|
||||
enableLeftDrag={enableLeftDrag}
|
||||
enableRightDrag={enableRightDrag}
|
||||
enableReorder={enableReorder}
|
||||
/>
|
||||
</ChartContextProvider>
|
||||
);
|
||||
|
@ -5,10 +5,32 @@ export type allViewsType = {
|
||||
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 {
|
||||
allViews: allViewsType[];
|
||||
currentView: "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year";
|
||||
currentViewData: any;
|
||||
currentViewData: ChartDataType | undefined;
|
||||
renderView: any;
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
// types
|
||||
import { ChartDataType } from "../types";
|
||||
import { ChartDataType, IGanttBlock } from "../types";
|
||||
// data
|
||||
import { weeks, months } from "../data";
|
||||
// helpers
|
||||
@ -19,7 +19,35 @@ type GetAllDaysInMonthInMonthViewType = {
|
||||
active: 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 numberOfDaysInMonth = getNumberOfDaysInMonth(month, year);
|
||||
const currentDate = new Date();
|
||||
@ -45,7 +73,7 @@ const getAllDaysInMonthInMonthView = (month: number, year: number) => {
|
||||
return day;
|
||||
};
|
||||
|
||||
const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number) => {
|
||||
const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number): IMonthBlock => {
|
||||
const currentMonth: number = month;
|
||||
const currentYear: number = year;
|
||||
|
||||
@ -162,7 +190,11 @@ export const getNumberOfDaysBetweenTwoDatesInMonth = (startDate: Date, endDate:
|
||||
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 scrollWidth: number = 0;
|
||||
|
||||
|
@ -100,8 +100,6 @@ export const generateYearChart = (yearPayload: ChartDataType, side: null | "left
|
||||
.map((monthData: any) => monthData.children.length)
|
||||
.reduce((partialSum: number, a: number) => partialSum + a, 0) * yearPayload.data.width;
|
||||
|
||||
console.log("scrollWidth", scrollWidth);
|
||||
|
||||
return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth };
|
||||
};
|
||||
|
||||
|
@ -1,20 +1,27 @@
|
||||
import { FC } from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { GanttChartRoot } from "components/gantt-chart";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
|
||||
// hooks
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useUser from "hooks/use-user";
|
||||
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: FC<Props> = ({}) => {
|
||||
export const IssueGanttChartView = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { orderBy } = useIssuesView();
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { ganttIssues, mutateGanttIssues } = useGanttChartIssues(
|
||||
workspaceSlug as string,
|
||||
projectId as string
|
||||
@ -31,76 +38,19 @@ export const IssueGanttChartView: FC<Props> = ({}) => {
|
||||
</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 (
|
||||
<div className="w-full h-full">
|
||||
<GanttChartRoot
|
||||
border={false}
|
||||
title="Issues"
|
||||
loaderTitle="Issues"
|
||||
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
|
||||
blockUpdateHandler={handleUpdateDates}
|
||||
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
|
||||
blockUpdateHandler={(block, payload) =>
|
||||
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
|
||||
}
|
||||
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>
|
||||
);
|
||||
|
@ -18,7 +18,7 @@ export const ViewIssueLabel: React.FC<Props> = ({ issue, maxRender = 1 }) => (
|
||||
{issue.label_details.map((label, index) => (
|
||||
<div
|
||||
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}>
|
||||
<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
|
||||
position="top"
|
||||
tooltipHeading="Labels"
|
||||
|
@ -248,7 +248,11 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
await addIssueToModule(res.id, payload.module);
|
||||
|
||||
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 (groupedIssues) mutateMyIssues();
|
||||
|
||||
|
@ -120,7 +120,7 @@ export const MyIssuesViewOptions: React.FC = () => {
|
||||
{({ open }) => (
|
||||
<>
|
||||
<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
|
||||
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
|
||||
: "text-custom-sidebar-text-200"
|
||||
|
@ -93,13 +93,14 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user, disabled = false }
|
||||
});
|
||||
};
|
||||
|
||||
const completedSubIssues = subIssuesResponse
|
||||
? subIssuesResponse.state_distribution.completed +
|
||||
subIssuesResponse.state_distribution.cancelled
|
||||
: 0;
|
||||
const completedSubIssue = subIssuesResponse?.state_distribution.completed ?? 0;
|
||||
const cancelledSubIssue = subIssuesResponse?.state_distribution.cancelled ?? 0;
|
||||
|
||||
const totalCompletedSubIssues = completedSubIssue + cancelledSubIssue;
|
||||
|
||||
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;
|
||||
|
||||
|
@ -8,7 +8,7 @@ import useSWR from "swr";
|
||||
import projectService from "services/project.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
// ui
|
||||
import { AssigneesList, Avatar, CustomSearchSelect, Tooltip } from "components/ui";
|
||||
import { AssigneesList, Avatar, CustomSearchSelect, Icon, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { UserGroupIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
@ -73,11 +73,11 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
|
||||
>
|
||||
{issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? (
|
||||
<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 className="flex items-center justify-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-custom-text-200" />
|
||||
<div className="flex items-center justify-center gap-2 px-1.5 py-1 rounded shadow-sm border border-custom-border-300">
|
||||
<Icon iconName="person" className="text-sm !leading-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -87,6 +87,7 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={issue.assignees}
|
||||
buttonClassName="!p-0"
|
||||
onChange={(data: any) => {
|
||||
const newData = issue.assignees ?? [];
|
||||
|
||||
|
@ -67,14 +67,8 @@ export const ViewPrioritySelect: React.FC<Props> = ({
|
||||
noBorder
|
||||
? ""
|
||||
: issue.priority === "urgent"
|
||||
? "border-red-500/20 bg-red-500/20"
|
||||
: issue.priority === "high"
|
||||
? "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"
|
||||
? "border-red-500/20 bg-red-500"
|
||||
: "border-custom-border-300 bg-custom-background-100"
|
||||
} items-center`}
|
||||
>
|
||||
<Tooltip
|
||||
@ -87,7 +81,7 @@ export const ViewPrioritySelect: React.FC<Props> = ({
|
||||
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
|
||||
`text-sm ${
|
||||
issue.priority === "urgent"
|
||||
? "text-red-500"
|
||||
? "text-white"
|
||||
: issue.priority === "high"
|
||||
? "text-orange-500"
|
||||
: issue.priority === "medium"
|
||||
|
@ -45,7 +45,7 @@ export const ViewStartDateSelect: React.FC<Props> = ({
|
||||
>
|
||||
<div className="group flex-shrink-0 relative max-w-[6.5rem]">
|
||||
<CustomDatePicker
|
||||
placeholder="Due date"
|
||||
placeholder="Start date"
|
||||
value={issue?.start_date}
|
||||
onChange={(val) => {
|
||||
partialUpdateIssue(
|
||||
|
@ -74,9 +74,9 @@ export const ViewStateSelect: React.FC<Props> = ({
|
||||
position={tooltipPosition}
|
||||
>
|
||||
<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 &&
|
||||
getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)}
|
||||
getStateGroupIcon(selectedOption.group, "14", "14", selectedOption.color)}
|
||||
</span>
|
||||
<span className="truncate">{selectedOption?.name ?? "State"}</span>
|
||||
</div>
|
||||
@ -131,6 +131,7 @@ export const ViewStateSelect: React.FC<Props> = ({
|
||||
disabled={isNotAllowed}
|
||||
onOpen={() => setFetchStates(true)}
|
||||
noChevron
|
||||
selfPositioned={selfPositioned}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -20,6 +20,7 @@ import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import type { ICurrentUserResponse, IIssueLabels, IState } from "types";
|
||||
// constants
|
||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
import { LABEL_COLOR_OPTIONS, getRandomLabelColor } from "constants/label";
|
||||
|
||||
// types
|
||||
type Props = {
|
||||
@ -52,10 +53,15 @@ export const CreateLabelModal: React.FC<Props> = ({
|
||||
watch,
|
||||
control,
|
||||
reset,
|
||||
setValue,
|
||||
} = useForm<IIssueLabels>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setValue("color", getRandomLabelColor());
|
||||
}, [setValue, isOpen]);
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
reset(defaultValues);
|
||||
@ -156,6 +162,7 @@ export const CreateLabelModal: React.FC<Props> = ({
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TwitterPicker
|
||||
color={value}
|
||||
colors={LABEL_COLOR_OPTIONS}
|
||||
onChange={(value) => {
|
||||
onChange(value.hex);
|
||||
close();
|
||||
|
@ -22,12 +22,14 @@ import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
import { getRandomLabelColor, LABEL_COLOR_OPTIONS } from "constants/label";
|
||||
|
||||
type Props = {
|
||||
labelForm: boolean;
|
||||
setLabelForm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isUpdating: boolean;
|
||||
labelToUpdate: IIssueLabels | null;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IIssueLabels> = {
|
||||
@ -35,167 +37,180 @@ const defaultValues: Partial<IIssueLabels> = {
|
||||
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(
|
||||
{ labelForm, setLabelForm, isUpdating, labelToUpdate },
|
||||
ref
|
||||
) {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { user } = useUserAuth();
|
||||
const { user } = useUserAuth();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
register,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<IIssueLabels>({
|
||||
defaultValues,
|
||||
});
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
register,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<IIssueLabels>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
const handleClose = () => {
|
||||
setLabelForm(false);
|
||||
reset(defaultValues);
|
||||
if (onClose) onClose();
|
||||
};
|
||||
|
||||
await issuesService
|
||||
.createIssueLabel(workspaceSlug as string, projectId as string, formData, user)
|
||||
.then((res) => {
|
||||
mutate<IIssueLabels[]>(
|
||||
PROJECT_ISSUE_LABELS(projectId as string),
|
||||
(prevData) => [res, ...(prevData ?? [])],
|
||||
false
|
||||
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
|
||||
await issuesService
|
||||
.createIssueLabel(workspaceSlug as string, projectId as string, formData, user)
|
||||
.then((res) => {
|
||||
mutate<IIssueLabels[]>(
|
||||
PROJECT_ISSUE_LABELS(projectId as string),
|
||||
(prevData) => [res, ...(prevData ?? [])],
|
||||
false
|
||||
);
|
||||
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);
|
||||
setLabelForm(false);
|
||||
});
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const handleLabelUpdate: SubmitHandler<IIssueLabels> = async (formData) => {
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
setValue("color", getRandomLabelColor());
|
||||
}, [labelToUpdate, setValue]);
|
||||
|
||||
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
|
||||
);
|
||||
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);
|
||||
}}
|
||||
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}
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
{isUpdating ? (
|
||||
<PrimaryButton onClick={handleSubmit(handleLabelUpdate)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Updating" : "Update"}
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton onClick={handleSubmit(handleLabelCreate)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Adding" : "Add"}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
<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
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -1,13 +1,20 @@
|
||||
import { FC } from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { GanttChartRoot } from "components/gantt-chart";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
|
||||
// hooks
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useUser from "hooks/use-user";
|
||||
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 = {};
|
||||
|
||||
@ -15,6 +22,10 @@ export const ModuleIssuesGanttChartView: FC<Props> = ({}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, moduleId } = router.query;
|
||||
|
||||
const { orderBy } = useIssuesView();
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { ganttIssues, mutateGanttIssues } = useGanttChartModuleIssues(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
@ -32,77 +43,18 @@ export const ModuleIssuesGanttChartView: FC<Props> = ({}) => {
|
||||
</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 (
|
||||
<div className="w-full h-full p-3">
|
||||
<GanttChartRoot
|
||||
title="Modules"
|
||||
loaderTitle="Modules"
|
||||
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
|
||||
blockUpdateHandler={handleUpdateDates}
|
||||
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
|
||||
blockUpdateHandler={(block, payload) =>
|
||||
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
|
||||
}
|
||||
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>
|
||||
);
|
||||
|
@ -1,11 +1,15 @@
|
||||
import { FC } from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { KeyedMutator } from "swr";
|
||||
|
||||
// services
|
||||
import modulesService from "services/modules.service";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// components
|
||||
import { GanttChartRoot } from "components/gantt-chart";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
import { GanttChartRoot, IBlockUpdateData, ModuleGanttBlock } from "components/gantt-chart";
|
||||
// types
|
||||
import { IModule } from "types";
|
||||
// constants
|
||||
@ -13,11 +17,14 @@ import { MODULE_STATUS } from "constants/module";
|
||||
|
||||
type Props = {
|
||||
modules: IModule[];
|
||||
mutateModules: KeyedMutator<IModule[]>;
|
||||
};
|
||||
|
||||
export const ModulesListGanttChartView: FC<Props> = ({ modules }) => {
|
||||
export const ModulesListGanttChartView: FC<Props> = ({ modules, mutateModules }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
// rendering issues on gantt sidebar
|
||||
const GanttSidebarBlockView = ({ data }: any) => (
|
||||
@ -32,42 +39,52 @@ export const ModulesListGanttChartView: FC<Props> = ({ modules }) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// rendering issues on gantt card
|
||||
const GanttBlockView = ({ data }: { data: IModule }) => (
|
||||
<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>
|
||||
);
|
||||
const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => {
|
||||
if (!workspaceSlug || !user) return;
|
||||
|
||||
// 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,
|
||||
};
|
||||
mutateModules((prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const newList = prevData.map((p) => ({
|
||||
...p,
|
||||
...(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.map((_block: any) => {
|
||||
if (_block?.start_date && _block.target_date) console.log("_block", _block);
|
||||
return {
|
||||
start_date: new Date(_block.created_at),
|
||||
target_date: new Date(_block.updated_at),
|
||||
data: _block,
|
||||
};
|
||||
})
|
||||
? blocks
|
||||
.filter((b) => b.start_date && b.target_date)
|
||||
.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 ?? ""),
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
@ -76,9 +93,9 @@ export const ModulesListGanttChartView: FC<Props> = ({ modules }) => {
|
||||
title="Modules"
|
||||
loaderTitle="Modules"
|
||||
blocks={modules ? blockFormat(modules) : null}
|
||||
blockUpdateHandler={handleUpdateDates}
|
||||
blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)}
|
||||
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
|
||||
blockRender={(data: any) => <GanttBlockView data={data} />}
|
||||
blockRender={(data: any) => <ModuleGanttBlock module={data as IModule} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -84,7 +84,7 @@ export const NotificationPopover = () => {
|
||||
disabled={!store?.theme?.sidebarCollapsed}
|
||||
>
|
||||
<Popover.Button
|
||||
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
|
||||
className={`relative group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
|
||||
isActive
|
||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
||||
@ -93,9 +93,13 @@ export const NotificationPopover = () => {
|
||||
<NotificationsOutlined fontSize="small" />
|
||||
{store?.theme?.sidebarCollapsed ? null : <span>Notifications</span>}
|
||||
{totalNotificationCount && totalNotificationCount > 0 ? (
|
||||
<span className="ml-auto bg-custom-primary-300 rounded-full text-xs text-white px-1.5">
|
||||
{getNumberCount(totalNotificationCount)}
|
||||
</span>
|
||||
store?.theme?.sidebarCollapsed ? (
|
||||
<span className="absolute right-3.5 top-2 h-2 w-2 bg-custom-primary-300 rounded-full" />
|
||||
) : (
|
||||
<span className="ml-auto bg-custom-primary-300 rounded-full text-xs text-white px-1.5">
|
||||
{getNumberCount(totalNotificationCount)}
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
</Popover.Button>
|
||||
</Tooltip>
|
||||
@ -108,7 +112,7 @@ export const NotificationPopover = () => {
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
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
|
||||
notificationCount={notificationCount}
|
||||
notificationMutate={notificationMutate}
|
||||
|
@ -146,7 +146,7 @@ export const ProfileIssuesViewOptions: React.FC = () => {
|
||||
{({ open }) => (
|
||||
<>
|
||||
<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
|
||||
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
|
||||
: "text-custom-sidebar-text-200"
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState, FC } from "react";
|
||||
import React, { useState, FC, useRef, useEffect } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import { mutate } from "swr";
|
||||
@ -6,7 +6,7 @@ import { mutate } from "swr";
|
||||
// react-beautiful-dnd
|
||||
import { DragDropContext, Draggable, DropResult, Droppable } from "react-beautiful-dnd";
|
||||
// headless ui
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useTheme from "hooks/use-theme";
|
||||
@ -36,6 +36,10 @@ export const ProjectSidebarList: FC = () => {
|
||||
const [projectToDelete, setProjectToDelete] = useState<IProject | null>(null);
|
||||
|
||||
// router
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
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);
|
||||
|
||||
updatedSortOrder = Math.round(
|
||||
(destinationSortingOrder + relativeDestinationSortingOrder) / 2
|
||||
);
|
||||
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<DeleteProjectModal
|
||||
@ -134,7 +157,12 @@ export const ProjectSidebarList: FC = () => {
|
||||
data={projectToDelete}
|
||||
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}>
|
||||
<Droppable droppableId="favorite-projects">
|
||||
{(provided) => (
|
||||
@ -147,7 +175,7 @@ export const ProjectSidebarList: FC = () => {
|
||||
<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"
|
||||
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
|
||||
<Icon
|
||||
@ -199,37 +227,57 @@ export const ProjectSidebarList: FC = () => {
|
||||
{({ 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"
|
||||
>
|
||||
Projects
|
||||
<Icon
|
||||
iconName={open ? "arrow_drop_down" : "arrow_right"}
|
||||
className="group-hover:opacity-100 opacity-0 !text-lg"
|
||||
/>
|
||||
</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">
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex items-center gap-1 font-semibold text-left whitespace-nowrap"
|
||||
>
|
||||
Projects
|
||||
<Icon
|
||||
iconName={open ? "arrow_drop_down" : "arrow_right"}
|
||||
className="group-hover:opacity-100 opacity-0 !text-lg"
|
||||
/>
|
||||
</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">
|
||||
{orderedJoinedProjects.map((project, index) => (
|
||||
<Draggable key={project.id} draggableId={project.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div ref={provided.innerRef} {...provided.draggableProps}>
|
||||
<SingleSidebarProject
|
||||
key={project.id}
|
||||
project={project}
|
||||
sidebarCollapse={store?.theme?.sidebarCollapsed}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
handleDeleteProject={() => handleDeleteProject(project)}
|
||||
handleCopyText={() => handleCopyText(project.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Disclosure.Panel as="div" className="space-y-2">
|
||||
{orderedJoinedProjects.map((project, index) => (
|
||||
<Draggable key={project.id} draggableId={project.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div ref={provided.innerRef} {...provided.draggableProps}>
|
||||
<SingleSidebarProject
|
||||
key={project.id}
|
||||
project={project}
|
||||
sidebarCollapse={store?.theme?.sidebarCollapsed}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
handleDeleteProject={() => handleDeleteProject(project)}
|
||||
handleCopyText={() => handleCopyText(project.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
{provided.placeholder}
|
||||
</>
|
||||
)}
|
||||
@ -239,43 +287,7 @@ export const ProjectSidebarList: FC = () => {
|
||||
)}
|
||||
</Droppable>
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
@ -210,7 +210,7 @@ export const SingleProjectCard: React.FC<ProjectCardProps> = ({
|
||||
{project.is_favorite ? (
|
||||
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
|
||||
<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>
|
||||
</CustomMenu.MenuItem>
|
||||
|
@ -12,7 +12,7 @@ import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { CustomMenu, Tooltip } from "components/ui";
|
||||
import { CustomMenu, Icon, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { EllipsisVerticalIcon, LinkIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
@ -90,6 +90,8 @@ export const SingleSidebarProject: React.FC<Props> = ({
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const isAdmin = project.member_role === 20;
|
||||
|
||||
const handleAddToFavorites = () => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
@ -152,7 +154,7 @@ export const SingleSidebarProject: React.FC<Props> = ({
|
||||
>
|
||||
<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"
|
||||
} ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""}`}
|
||||
{...provided?.dragHandleProps}
|
||||
@ -204,15 +206,19 @@ export const SingleSidebarProject: React.FC<Props> = ({
|
||||
fontSize="small"
|
||||
className={`flex-shrink-0 ${
|
||||
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>
|
||||
</Tooltip>
|
||||
|
||||
{!sidebarCollapse && (
|
||||
<CustomMenu className="hidden group-hover:block flex-shrink-0" ellipsis>
|
||||
{!shortContextMenu && (
|
||||
<CustomMenu
|
||||
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}>
|
||||
<span className="flex items-center justify-start gap-2 ">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
@ -231,7 +237,7 @@ export const SingleSidebarProject: React.FC<Props> = ({
|
||||
{project.is_favorite && (
|
||||
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
|
||||
<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>
|
||||
</CustomMenu.MenuItem>
|
||||
@ -254,6 +260,14 @@ export const SingleSidebarProject: React.FC<Props> = ({
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
@ -3,6 +3,8 @@ import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// component
|
||||
import { Icon } from "components/ui";
|
||||
// services
|
||||
import workspaceService from "services/workspace.service";
|
||||
// icons
|
||||
@ -23,12 +25,14 @@ type AvatarProps = {
|
||||
export const Avatar: React.FC<AvatarProps> = ({
|
||||
user,
|
||||
index,
|
||||
height = "20px",
|
||||
width = "20px",
|
||||
height = "24px",
|
||||
width = "24px",
|
||||
fontSize = "12px",
|
||||
}) => (
|
||||
<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={{
|
||||
height: height,
|
||||
width: width,
|
||||
@ -36,8 +40,8 @@ export const Avatar: React.FC<AvatarProps> = ({
|
||||
>
|
||||
{user && user.avatar && user.avatar !== "" ? (
|
||||
<div
|
||||
className={`rounded-full border-2 ${
|
||||
index ? "border-custom-border-200 bg-custom-background-80" : "border-transparent"
|
||||
className={`rounded border-[0.5px] ${
|
||||
index ? "border-custom-border-200 bg-custom-background-100" : "border-transparent"
|
||||
}`}
|
||||
style={{
|
||||
height: height,
|
||||
@ -46,13 +50,13 @@ export const Avatar: React.FC<AvatarProps> = ({
|
||||
>
|
||||
<img
|
||||
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}
|
||||
/>
|
||||
</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={{
|
||||
height: height,
|
||||
width: width,
|
||||
@ -75,7 +79,7 @@ type AsigneesListProps = {
|
||||
export const AssigneesList: React.FC<AsigneesListProps> = ({
|
||||
users,
|
||||
userIds,
|
||||
length = 5,
|
||||
length = 3,
|
||||
showLength = true,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
@ -88,7 +92,7 @@ export const AssigneesList: React.FC<AsigneesListProps> = ({
|
||||
|
||||
if ((users && users.length === 0) || (userIds && userIds.length === 0))
|
||||
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" />
|
||||
</div>
|
||||
);
|
||||
@ -100,7 +104,14 @@ export const AssigneesList: React.FC<AsigneesListProps> = ({
|
||||
{users.slice(0, length).map((user, 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 && (
|
||||
@ -112,7 +123,12 @@ export const AssigneesList: React.FC<AsigneesListProps> = ({
|
||||
})}
|
||||
{showLength ? (
|
||||
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
|
||||
) : (
|
||||
""
|
||||
|
@ -1,13 +1,19 @@
|
||||
import { FC } from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { GanttChartRoot } from "components/gantt-chart";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
|
||||
// hooks
|
||||
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 = {};
|
||||
|
||||
@ -15,6 +21,8 @@ export const ViewIssuesGanttChartView: FC<Props> = ({}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, viewId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { ganttIssues, mutateGanttIssues } = useGanttChartViewIssues(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
@ -32,77 +40,17 @@ export const ViewIssuesGanttChartView: FC<Props> = ({}) => {
|
||||
</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 (
|
||||
<div className="w-full h-full p-3">
|
||||
<GanttChartRoot
|
||||
title="Issue Views"
|
||||
loaderTitle="Issue Views"
|
||||
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
|
||||
blockUpdateHandler={handleUpdateDates}
|
||||
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
|
||||
blockUpdateHandler={(block, payload) =>
|
||||
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
|
||||
}
|
||||
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
|
||||
blockRender={(data: any) => <GanttBlockView data={data} />}
|
||||
blockRender={(data: any) => <IssueGanttBlock issue={data as IIssue} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -50,8 +50,10 @@ export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, u
|
||||
const canDelete = confirmWorkspaceName === data?.name && confirmDeleteMyWorkspace;
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setIsDeleteLoading(false);
|
||||
setConfirmWorkspaceName("");
|
||||
setConfirmDeleteMyWorkspace(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDeletion = async () => {
|
||||
|
@ -9,3 +9,4 @@ export * from "./issues-stats";
|
||||
export * from "./settings-header";
|
||||
export * from "./sidebar-dropdown";
|
||||
export * from "./sidebar-menu";
|
||||
export * from "./sidebar-quick-action";
|
||||
|
@ -146,13 +146,12 @@ export const WorkspaceSidebarDropdown = () => {
|
||||
>
|
||||
<Menu.Items
|
||||
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="text-sm text-custom-sidebar-text-200">{user?.display_name}</div>
|
||||
<span className="text-sm font-semibold text-custom-sidebar-text-200">Workspace</span>
|
||||
<span className="text-sm font-medium text-custom-sidebar-text-200">Workspace</span>
|
||||
{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.map((workspace) => (
|
||||
<Menu.Item key={workspace.id}>
|
||||
@ -160,7 +159,7 @@ export const WorkspaceSidebarDropdown = () => {
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
<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">
|
||||
<CheckIcon
|
||||
className={`h-3 w-3.5 text-custom-sidebar-text-100 ${
|
||||
active || workspace.id === activeWorkspace?.id
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
workspace.id === activeWorkspace?.id ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
@ -205,9 +202,9 @@ export const WorkspaceSidebarDropdown = () => {
|
||||
onClick={() => {
|
||||
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
|
||||
</Menu.Item>
|
||||
</div>
|
||||
@ -263,16 +260,17 @@ export const WorkspaceSidebarDropdown = () => {
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
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"
|
||||
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-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(
|
||||
(link, index) => (
|
||||
<Menu.Item key={index} as="button" type="button">
|
||||
<Link href={link.link}>
|
||||
<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}
|
||||
</a>
|
||||
</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"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
<Icon iconName="logout" className="!text-base" />
|
||||
<Icon iconName="logout" className="!text-lg !leading-5" />
|
||||
Sign out
|
||||
</Menu.Item>
|
||||
</div>
|
||||
|
@ -51,7 +51,7 @@ export const WorkspaceSidebarMenu = () => {
|
||||
const { collapsed: sidebarCollapse } = useTheme();
|
||||
|
||||
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) => {
|
||||
const isActive =
|
||||
link.name === "Settings"
|
||||
|
47
apps/app/components/workspace/sidebar-quick-action.tsx
Normal file
47
apps/app/components/workspace/sidebar-quick-action.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -2,13 +2,23 @@ import { objToQueryParams } from "helpers/string.helper";
|
||||
import { IAnalyticsParams, IJiraMetadata, INotificationParams } from "types";
|
||||
|
||||
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 priorityKey = priority ? priority.split(",") : [];
|
||||
let assigneesKey = assignees ? assignees.split(",") : [];
|
||||
let createdByKey = created_by ? created_by.split(",") : [];
|
||||
let labelsKey = labels ? labels.split(",") : [];
|
||||
const startTargetDate = start_target_date ? `${start_target_date}`.toUpperCase() : "FALSE";
|
||||
const targetDateKey = target_date ?? "";
|
||||
const type = params.type ? params.type.toUpperCase() : "NULL";
|
||||
const groupBy = params.group_by ? params.group_by.toUpperCase() : "NULL";
|
||||
@ -21,7 +31,7 @@ const paramsToKey = (params: any) => {
|
||||
createdByKey = createdByKey.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) => {
|
||||
|
17
apps/app/constants/label.ts
Normal file
17
apps/app/constants/label.ts
Normal 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];
|
||||
};
|
@ -43,6 +43,14 @@ export const SPREADSHEET_COLUMN = [
|
||||
ascendingOrder: "labels__name",
|
||||
descendingOrder: "-labels__name",
|
||||
},
|
||||
{
|
||||
propertyName: "start_date",
|
||||
colName: "Start Date",
|
||||
colSize: "128px",
|
||||
icon: CalendarDaysIcon,
|
||||
ascendingOrder: "-start_date",
|
||||
descendingOrder: "start_date",
|
||||
},
|
||||
{
|
||||
propertyName: "due_date",
|
||||
colName: "Due Date",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user