diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 683ed9670..5855f0413 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -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 diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 5b7bb7598..1abd63b7f 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -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() diff --git a/apiserver/plane/api/serializers/exporter.py b/apiserver/plane/api/serializers/exporter.py new file mode 100644 index 000000000..5c78cfa69 --- /dev/null +++ b/apiserver/plane/api/serializers/exporter.py @@ -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 diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 770880ef0..64ee2b8f7 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -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", ] diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 643518daa..af60be89c 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -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", + ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index b1231f1a4..b8743476e 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -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//projects//project-deploy-boards/", + ProjectDeployBoardViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-deploy-board", + ), + path( + "workspaces//projects//project-deploy-boards//", + ProjectDeployBoardViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-deploy-board", + ), + path( + "public/workspaces//project-boards//settings/", + ProjectDeployBoardPublicSettingsEndpoint.as_view(), + name="project-deploy-board-settings", + ), + path( + "public/workspaces//project-boards//issues/", + ProjectDeployBoardIssuesPublicEndpoint.as_view(), + name="project-deploy-board", + ), + path( + "public/workspaces//project-boards//issues//comments/", + IssueCommentPublicViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="issue-comments-project-board", + ), + path( + "public/workspaces//project-boards//issues//comments//", + IssueCommentPublicViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="issue-comments-project-board", + ), + path( + "public/workspaces//project-boards//issues//reactions/", + IssueReactionPublicViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="issue-reactions-project-board", + ), + path( + "public/workspaces//project-boards//issues//reactions//", + IssueReactionPublicViewSet.as_view( + { + "delete": "destroy", + } + ), + name="issue-reactions-project-board", + ), + path( + "public/workspaces//project-boards//comments//reactions/", + CommentReactionPublicViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="comment-reactions-project-board", + ), + path( + "public/workspaces//project-boards//comments//reactions//", + CommentReactionPublicViewSet.as_view( + { + "delete": "destroy", + } + ), + name="comment-reactions-project-board", + ), + path( + "public/workspaces//project-boards//inboxes//inbox-issues/", + InboxIssuePublicViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="inbox-issue", + ), + path( + "public/workspaces//project-boards//inboxes//inbox-issues//", + InboxIssuePublicViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="inbox-issue", + ), + path( + "public/workspaces//project-boards//issues//votes/", + IssueVotePublicViewSet.as_view( + { + "get": "list", + "post": "create", + "delete": "destroy", + } + ), + name="issue-vote-project-board", + ), + ## End Public Boards ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index a02e22fe9..11223f90a 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -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 \ No newline at end of file +from .notification import NotificationViewSet, UnreadNotificationEndpoint + +from .exporter import ( + ExportIssuesEndpoint, +) \ No newline at end of file diff --git a/apiserver/plane/api/views/analytic.py b/apiserver/plane/api/views/analytic.py index 7d5786c19..feb766b46 100644 --- a/apiserver/plane/api/views/analytic.py +++ b/apiserver/plane/api/views/analytic.py @@ -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") ) diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 268485b6e..a3d89fa81 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -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( diff --git a/apiserver/plane/api/views/exporter.py b/apiserver/plane/api/views/exporter.py new file mode 100644 index 000000000..f158f783d --- /dev/null +++ b/apiserver/plane/api/views/exporter.py @@ -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, + ) diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index ada76c9b3..4fbea5f87 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -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, - ) \ No newline at end of file + ) + + +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", "

" + ), + 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, + ) + diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 95e598dae..77432e1e0 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -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 = [ diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 2a7532ecf..1cd741f84 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -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( diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 98484f74b..6d570865d 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -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, + ) diff --git a/apiserver/plane/api/views/view.py b/apiserver/plane/api/views/view.py index 874bb94fb..32ba24c8b 100644 --- a/apiserver/plane/api/views/view.py +++ b/apiserver/plane/api/views/view.py @@ -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) diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py new file mode 100644 index 000000000..de15bcdb8 --- /dev/null +++ b/apiserver/plane/bgtasks/export_task.py @@ -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 diff --git a/apiserver/plane/bgtasks/exporter_expired_task.py b/apiserver/plane/bgtasks/exporter_expired_task.py new file mode 100644 index 000000000..799904347 --- /dev/null +++ b/apiserver/plane/bgtasks/exporter_expired_task.py @@ -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) diff --git a/apiserver/plane/bgtasks/project_issue_export.py b/apiserver/plane/bgtasks/project_issue_export.py deleted file mode 100644 index 75088be9d..000000000 --- a/apiserver/plane/bgtasks/project_issue_export.py +++ /dev/null @@ -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 diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index ed0dc419e..15fe8af52 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -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. diff --git a/apiserver/plane/db/migrations/0041_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0041_alter_analyticview_created_by_and_more.py new file mode 100644 index 000000000..bf0a4341f --- /dev/null +++ b/apiserver/plane/db/migrations/0041_alter_analyticview_created_by_and_more.py @@ -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')}, + }, + ), + ] diff --git a/apiserver/plane/db/migrations/0042_auto_20230809_1745.py b/apiserver/plane/db/migrations/0042_auto_20230809_1745.py new file mode 100644 index 000000000..8bac2d954 --- /dev/null +++ b/apiserver/plane/db/migrations/0042_auto_20230809_1745.py @@ -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), + ] diff --git a/apiserver/plane/db/migrations/0043_auto_20230809_1645.py b/apiserver/plane/db/migrations/0043_auto_20230809_1645.py new file mode 100644 index 000000000..3dbbc44a2 --- /dev/null +++ b/apiserver/plane/db/migrations/0043_auto_20230809_1645.py @@ -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), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 959dea5f7..659eea3eb 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -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 \ No newline at end of file +from .notification import Notification + +from .exporter import ExporterHistory \ No newline at end of file diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index c8c43cef4..56301e3d3 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -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}>" diff --git a/apiserver/plane/db/models/exporter.py b/apiserver/plane/db/models/exporter.py new file mode 100644 index 000000000..fce31c8e7 --- /dev/null +++ b/apiserver/plane/db/models/exporter.py @@ -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}>" \ No newline at end of file diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 2a4462942..7af9e6e14 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -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) diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index 8ad0ec838..ad1e16080 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -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}" diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 2cbd70369..0c2b5cb96 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -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}>" diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 09db42002..48d8c9f2d 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -33,6 +33,7 @@ def get_default_props(): "estimate": True, "created_on": True, "updated_on": True, + "start_date": True, }, "showEmptyGroups": True, } diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index e3a918c18..59e0bd31b 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -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") diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index a7a946e60..34e1e8203 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -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(): diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 76c3dace9..ca9d881ef 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -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 \ No newline at end of file +scout-apm==2.26.1 +openpyxl==3.1.2 \ No newline at end of file diff --git a/apps/app/components/analytics/scope-and-demand/leaderboard.tsx b/apps/app/components/analytics/scope-and-demand/leaderboard.tsx index c22f60aa9..156c09d4c 100644 --- a/apps/app/components/analytics/scope-and-demand/leaderboard.tsx +++ b/apps/app/components/analytics/scope-and-demand/leaderboard.tsx @@ -5,18 +5,23 @@ type Props = { firstName: string; lastName: string; count: number; + id: string; }[]; title: string; + workspaceSlug: string; }; -export const AnalyticsLeaderboard: React.FC = ({ users, title }) => ( +export const AnalyticsLeaderboard: React.FC = ({ users, title, workspaceSlug }) => (
{title}
{users.length > 0 ? (
{users.map((user) => ( -
@@ -38,7 +43,7 @@ export const AnalyticsLeaderboard: React.FC = ({ users, title }) => (
{user.count} -
+ ))}
) : ( diff --git a/apps/app/components/analytics/scope-and-demand/scope-and-demand.tsx b/apps/app/components/analytics/scope-and-demand/scope-and-demand.tsx index c4acf8f45..7f40ee79a 100644 --- a/apps/app/components/analytics/scope-and-demand/scope-and-demand.tsx +++ b/apps/app/components/analytics/scope-and-demand/scope-and-demand.tsx @@ -60,8 +60,10 @@ export const ScopeAndDemand: React.FC = ({ 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() ?? ""} /> ({ @@ -70,8 +72,10 @@ export const ScopeAndDemand: React.FC = ({ 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() ?? ""} />
diff --git a/apps/app/components/command-palette/change-interface-theme.tsx b/apps/app/components/command-palette/change-interface-theme.tsx index b34212b7f..489d8ac31 100644 --- a/apps/app/components/command-palette/change-interface-theme.tsx +++ b/apps/app/components/command-palette/change-interface-theme.tsx @@ -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>; @@ -22,6 +24,8 @@ export const ChangeInterfaceTheme: React.FC = ({ setIsPaletteOpen }) => { const updateUserTheme = (newTheme: string) => { if (!user) return; + unsetCustomCssVariables(); + setTheme(newTheme); mutateUser((prevData) => { diff --git a/apps/app/components/command-palette/command-k.tsx b/apps/app/components/command-palette/command-k.tsx index fc5c29615..a1525a348 100644 --- a/apps/app/components/command-palette/command-k.tsx +++ b/apps/app/components/command-palette/command-k.tsx @@ -354,8 +354,8 @@ export const CommandK: React.FC = ({ deleteIssue, isPaletteOpen, setIsPal { - 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 = ({ deleteIssue, isPaletteOpen, setIsPal { + setIsPaletteOpen(false); setPlaceholder("Change state..."); setSearchTerm(""); setPages([...pages, "change-issue-state"]); @@ -460,6 +461,7 @@ export const CommandK: React.FC = ({ deleteIssue, isPaletteOpen, setIsPal { + setIsPaletteOpen(false); const e = new KeyboardEvent("keydown", { key: "c", }); @@ -479,6 +481,7 @@ export const CommandK: React.FC = ({ deleteIssue, isPaletteOpen, setIsPal { + setIsPaletteOpen(false); const e = new KeyboardEvent("keydown", { key: "p", }); @@ -500,6 +503,7 @@ export const CommandK: React.FC = ({ deleteIssue, isPaletteOpen, setIsPal { + setIsPaletteOpen(false); const e = new KeyboardEvent("keydown", { key: "q", }); @@ -517,6 +521,7 @@ export const CommandK: React.FC = ({ deleteIssue, isPaletteOpen, setIsPal { + setIsPaletteOpen(false); const e = new KeyboardEvent("keydown", { key: "m", }); @@ -534,6 +539,7 @@ export const CommandK: React.FC = ({ deleteIssue, isPaletteOpen, setIsPal { + setIsPaletteOpen(false); const e = new KeyboardEvent("keydown", { key: "v", }); @@ -551,6 +557,7 @@ export const CommandK: React.FC = ({ deleteIssue, isPaletteOpen, setIsPal { + setIsPaletteOpen(false); const e = new KeyboardEvent("keydown", { key: "d", }); @@ -568,11 +575,12 @@ export const CommandK: React.FC = ({ deleteIssue, isPaletteOpen, setIsPal {projectDetails && projectDetails.inbox_view && ( + onSelect={() => { + setIsPaletteOpen(false); redirect( `/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}` - ) - } + ); + }} className="focus:outline-none" >
diff --git a/apps/app/components/core/activity.tsx b/apps/app/components/core/activity.tsx index 833f4fd16..7ddf9c33c 100644 --- a/apps/app/components/core/activity.tsx +++ b/apps/app/components/core/activity.tsx @@ -35,6 +35,22 @@ const IssueLink = ({ activity }: { activity: IIssueActivity }) => { ); }; +const UserLink = ({ activity }: { activity: IIssueActivity }) => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + return ( + + {activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value} + + ); +}; + 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{" "} - {activity.new_value} + added a new assignee {showIssue && ( <> {" "} @@ -60,8 +75,7 @@ const activityDetails: { else return ( <> - removed the assignee{" "} - {activity.old_value} + removed the assignee {showIssue && ( <> {" "} diff --git a/apps/app/components/core/filters/issues-view-filter.tsx b/apps/app/components/core/filters/issues-view-filter.tsx index ca2ea59f7..2fa80c975 100644 --- a/apps/app/components/core/filters/issues-view-filter.tsx +++ b/apps/app/components/core/filters/issues-view-filter.tsx @@ -113,49 +113,51 @@ export const IssuesFilterView: React.FC = () => { ))}
)} - { - const key = option.key as keyof typeof filters; + {issueView !== "gantt_chart" && ( + { + 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" + /> + )} {({ open }) => ( <> {
- {issueView !== "calendar" && issueView !== "spreadsheet" && ( - <> + {issueView !== "calendar" && + issueView !== "spreadsheet" && + issueView !== "gantt_chart" && (

Group by

@@ -206,34 +209,34 @@ export const IssuesFilterView: React.FC = () => {
-
-

Order by

-
- option.key === orderBy)?.name ?? - "Select" - } - className="!w-full" - buttonClassName="w-full" - > - {ORDER_BY_OPTIONS.map((option) => - groupByProperty === "priority" && - option.key === "priority" ? null : ( - { - setOrderBy(option.key); - }} - > - {option.name} - - ) - )} - -
+ )} + {issueView !== "calendar" && issueView !== "spreadsheet" && ( +
+

Order by

+
+ option.key === orderBy)?.name ?? + "Select" + } + className="!w-full" + buttonClassName="w-full" + > + {ORDER_BY_OPTIONS.map((option) => + groupByProperty === "priority" && option.key === "priority" ? null : ( + { + setOrderBy(option.key); + }} + > + {option.name} + + ) + )} +
- +
)}

Issue type

@@ -263,16 +266,19 @@ export const IssuesFilterView: React.FC = () => {
{issueView !== "calendar" && issueView !== "spreadsheet" && ( - <> -
-

Show sub-issues

-
- setShowSubIssues(!showSubIssues)} - /> -
+
+

Show sub-issues

+
+ setShowSubIssues(!showSubIssues)} + />
+
+ )} + {issueView !== "calendar" && + issueView !== "spreadsheet" && + issueView !== "gantt_chart" && (

Show empty states

@@ -282,6 +288,10 @@ export const IssuesFilterView: React.FC = () => { />
+ )} + {issueView !== "calendar" && + issueView !== "spreadsheet" && + issueView !== "gantt_chart" && (
- - )} + )}
-
-

Display Properties

-
- {Object.keys(properties).map((key) => { - if (key === "estimate" && !isEstimateActive) return null; + {issueView !== "gantt_chart" && ( +
+

Display Properties

+
+ {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 ( - - ); - })} + return ( + + ); + })} +
-
+ )}
diff --git a/apps/app/components/core/image-picker-popover.tsx b/apps/app/components/core/image-picker-popover.tsx index 7144bd01f..402cba022 100644 --- a/apps/app/components/core/image-picker-popover.tsx +++ b/apps/app/components/core/image-picker-popover.tsx @@ -27,8 +27,8 @@ const unsplashEnabled = const tabOptions = [ { - key: "unsplash", - title: "Unsplash", + key: "images", + title: "Images", }, { key: "upload", diff --git a/apps/app/components/core/theme/color-picker-input.tsx b/apps/app/components/core/theme/color-picker-input.tsx index f848dd59e..73d71e46b 100644 --- a/apps/app/components/core/theme/color-picker-input.tsx +++ b/apps/app/components/core/theme/color-picker-input.tsx @@ -21,13 +21,21 @@ import { ICustomTheme } from "types"; type Props = { name: keyof ICustomTheme; + position?: "left" | "right"; watch: UseFormWatch; setValue: UseFormSetValue; error: FieldError | Merge> | undefined; register: UseFormRegister; }; -export const ColorPickerInput: React.FC = ({ name, watch, setValue, error, register }) => { +export const ColorPickerInput: React.FC = ({ + 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 = ({ name, watch, setValue, error leaveFrom="opacity-100 translate-y-0" leaveTo="opacity-0 translate-y-1" > - + diff --git a/apps/app/components/core/theme/custom-theme-selector.tsx b/apps/app/components/core/theme/custom-theme-selector.tsx index 3f6752e99..27817c82a 100644 --- a/apps/app/components/core/theme/custom-theme-selector.tsx +++ b/apps/app/components/core/theme/custom-theme-selector.tsx @@ -83,6 +83,7 @@ export const CustomThemeSelector: React.FC = observer(({ preLoadedData }) = observer(({ preLoadedData }) = ({ >
@@ -155,11 +155,7 @@ export const BoardHeader: React.FC = ({ > {getGroupTitle()} - + {groupedIssues?.[groupTitle].length ?? 0}
@@ -174,9 +170,12 @@ export const BoardHeader: React.FC = ({ }} > {isCollapsed ? ( - + ) : ( - + )} {!disableUserActions && selectedGroup !== "created_by" && ( diff --git a/apps/app/components/core/views/board-view/single-issue.tsx b/apps/app/components/core/views/board-view/single-issue.tsx index 5aaa71407..689020bb5 100644 --- a/apps/app/components/core/views/board-view/single-issue.tsx +++ b/apps/app/components/core/views/board-view/single-issue.tsx @@ -232,7 +232,7 @@ export const SingleBoardIssue: React.FC = ({
= ({ {issue.project_detail.identifier}-{issue.sequence_id}
)} -
{issue.name}
+
{issue.name}
-
+
{properties.priority && ( = ({ issue={issue} partialUpdateIssue={partialUpdateIssue} isNotAllowed={isNotAllowed} + customButton user={user} selfPositioned /> diff --git a/apps/app/components/core/views/spreadsheet-view/single-issue.tsx b/apps/app/components/core/views/spreadsheet-view/single-issue.tsx index 5d9eb7c31..7e8fd0d26 100644 --- a/apps/app/components/core/views/spreadsheet-view/single-issue.tsx +++ b/apps/app/components/core/views/spreadsheet-view/single-issue.tsx @@ -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 = ({
)} + {properties.start_date && ( +
+ +
+ )} + {properties.due_date && (
; }; -export const CyclesListGanttChartView: FC = ({ cycles }) => { +export const CyclesListGanttChartView: FC = ({ 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 = ({ cycles }) => {
); - // rendering issues on gantt card - const GanttBlockView = ({ data }: { data: ICycle }) => ( - - -
- -
- {data?.name} -
-
-
- - ); + 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 (
handleCycleUpdate(block, payload)} sidebarBlockRender={(data: any) => } - blockRender={(data: any) => } + blockRender={(data: any) => } + enableLeftDrag={false} + enableRightDrag={false} />
); diff --git a/apps/app/components/cycles/cycles-list/all-cycles-list.tsx b/apps/app/components/cycles/cycles-list/all-cycles-list.tsx index 7ebd92a50..26bf0799c 100644 --- a/apps/app/components/cycles/cycles-list/all-cycles-list.tsx +++ b/apps/app/components/cycles/cycles-list/all-cycles-list.tsx @@ -17,7 +17,7 @@ export const AllCyclesList: React.FC = ({ 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 = ({ viewType }) => { : null ); - return ; + return ; }; diff --git a/apps/app/components/cycles/cycles-list/completed-cycles-list.tsx b/apps/app/components/cycles/cycles-list/completed-cycles-list.tsx index 79a427d95..552596d93 100644 --- a/apps/app/components/cycles/cycles-list/completed-cycles-list.tsx +++ b/apps/app/components/cycles/cycles-list/completed-cycles-list.tsx @@ -17,7 +17,7 @@ export const CompletedCyclesList: React.FC = ({ 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 = ({ viewType }) => { : null ); - return ; + return ; }; diff --git a/apps/app/components/cycles/cycles-list/draft-cycles-list.tsx b/apps/app/components/cycles/cycles-list/draft-cycles-list.tsx index fd2dccc93..05815dc9c 100644 --- a/apps/app/components/cycles/cycles-list/draft-cycles-list.tsx +++ b/apps/app/components/cycles/cycles-list/draft-cycles-list.tsx @@ -17,7 +17,7 @@ export const DraftCyclesList: React.FC = ({ 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 = ({ viewType }) => { : null ); - return ; + return ; }; diff --git a/apps/app/components/cycles/cycles-list/upcoming-cycles-list.tsx b/apps/app/components/cycles/cycles-list/upcoming-cycles-list.tsx index 140727cb8..eba212af2 100644 --- a/apps/app/components/cycles/cycles-list/upcoming-cycles-list.tsx +++ b/apps/app/components/cycles/cycles-list/upcoming-cycles-list.tsx @@ -17,7 +17,7 @@ export const UpcomingCyclesList: React.FC = ({ 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 = ({ viewType }) => { : null ); - return ; + return ; }; diff --git a/apps/app/components/cycles/cycles-view.tsx b/apps/app/components/cycles/cycles-view.tsx index 6f3fa336a..ede13b65e 100644 --- a/apps/app/components/cycles/cycles-view.tsx +++ b/apps/app/components/cycles/cycles-view.tsx @@ -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; viewType: string | null; }; -export const CyclesView: React.FC = ({ cycles, viewType }) => { +export const CyclesView: React.FC = ({ cycles, mutateCycles, viewType }) => { const [createUpdateCycleModal, setCreateUpdateCycleModal] = useState(false); const [selectedCycleToUpdate, setSelectedCycleToUpdate] = useState(null); @@ -202,7 +203,7 @@ export const CyclesView: React.FC = ({ cycles, viewType }) => { ))}
) : ( - + ) ) : (
diff --git a/apps/app/components/cycles/gantt-chart.tsx b/apps/app/components/cycles/gantt-chart.tsx index 0deb10079..fe276b50d 100644 --- a/apps/app/components/cycles/gantt-chart.tsx +++ b/apps/app/components/cycles/gantt-chart.tsx @@ -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 = ({}) => { +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 = ({}) => {
); - // rendering issues on gantt card - const GanttBlockView = ({ data }: any) => ( - - -
- -
- {data?.name} -
-
- {data.infoToggle && ( - -
- - info - -
-
- )} -
- - ); - - // 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 (
+ updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) + } sidebarBlockRender={(data: any) => } - blockRender={(data: any) => } + blockRender={(data: any) => } + enableReorder={orderBy === "sort_order"} />
); diff --git a/apps/app/components/gantt-chart/blocks/block.tsx b/apps/app/components/gantt-chart/blocks/block.tsx new file mode 100644 index 000000000..52fd1fe52 --- /dev/null +++ b/apps/app/components/gantt-chart/blocks/block.tsx @@ -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 ( + + +
+ +
{issue.name}
+
+ {renderShortDate(issue.start_date ?? "")} to{" "} + {renderShortDate(issue.target_date ?? "")} +
+
+ } + position="top-left" + > +
+ {issue.name} +
+ +
+ + ); +}; + +export const CycleGanttBlock = ({ cycle }: { cycle: ICycle }) => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + return ( + + +
+ +
{cycle.name}
+
+ {renderShortDate(cycle.start_date ?? "")} to {renderShortDate(cycle.end_date ?? "")} +
+
+ } + position="top-left" + > +
+ {cycle.name} +
+ +
+ + ); +}; + +export const ModuleGanttBlock = ({ module }: { module: IModule }) => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + return ( + + +
s.value === module.status)?.color }} + /> + +
{module.name}
+
+ {renderShortDate(module.start_date ?? "")} to{" "} + {renderShortDate(module.target_date ?? "")} +
+
+ } + position="top-left" + > +
+ {module.name} +
+ +
+ + ); +}; diff --git a/apps/app/components/gantt-chart/blocks/blocks-display.tsx b/apps/app/components/gantt-chart/blocks/blocks-display.tsx new file mode 100644 index 000000000..fd43c733e --- /dev/null +++ b/apps/app/components/gantt-chart/blocks/blocks-display.tsx @@ -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 ( +
+ + + {(droppableProvided, droppableSnapshot) => ( +
+ <> + {blocks && + blocks.length > 0 && + blocks.map( + (block, index: number) => + block.start_date && + block.target_date && ( + + {(provided) => ( +
+ handleChartBlockPosition(block, ...args)} + enableLeftDrag={enableLeftDrag} + enableRightDrag={enableRightDrag} + provided={provided} + > +
+ {blockRender({ + ...block.data, + })} +
+
+
+ )} +
+ ) + )} + {droppableProvided.placeholder} + +
+ )} +
+
+ + {/* sidebar */} + {/*
+ {blocks && + blocks.length > 0 && + blocks.map((block: any, _idx: number) => ( +
+ {sidebarBlockRender(block?.data)} +
+ ))} +
*/} +
+ ); +}; diff --git a/apps/app/components/gantt-chart/blocks/index.ts b/apps/app/components/gantt-chart/blocks/index.ts new file mode 100644 index 000000000..8773b2797 --- /dev/null +++ b/apps/app/components/gantt-chart/blocks/index.ts @@ -0,0 +1,2 @@ +export * from "./block"; +export * from "./blocks-display"; diff --git a/apps/app/components/gantt-chart/blocks/index.tsx b/apps/app/components/gantt-chart/blocks/index.tsx deleted file mode 100644 index dcc3a2910..000000000 --- a/apps/app/components/gantt-chart/blocks/index.tsx +++ /dev/null @@ -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 ( -
-
- {blocks && - blocks.length > 0 && - blocks.map((block: any, _idx: number) => ( - <> - {block.start_date && block.target_date && ( - -
-
-
- {block?.start_date ? datePreview(block?.start_date) : "-"} -
-
- -
- {blockRender({ - ...block?.data, - infoToggle: block?.infoToggle ? true : false, - })} -
- -
-
- {block?.target_date ? datePreview(block?.target_date) : "-"} -
-
-
-
- )} - - ))} -
- - {/* sidebar */} - {/*
- {blocks && - blocks.length > 0 && - blocks.map((block: any, _idx: number) => ( -
- {sidebarBlockRender(block?.data)} -
- ))} -
*/} -
- ); -}; diff --git a/apps/app/components/gantt-chart/chart/bi-week.tsx b/apps/app/components/gantt-chart/chart/bi-week.tsx index 1e1173ad4..3637f88ea 100644 --- a/apps/app/components/gantt-chart/chart/bi-week.tsx +++ b/apps/app/components/gantt-chart/chart/bi-week.tsx @@ -25,7 +25,7 @@ export const BiWeekChartView: FC = () => {
= () => {
= () => {
void; + blocks: IGanttBlock[] | null; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; sidebarBlockRender: FC; blockRender: FC; + enableLeftDrag: boolean; + enableRightDrag: boolean; + enableReorder: boolean; }; export const ChartViewRoot: FC = ({ @@ -54,6 +52,9 @@ export const ChartViewRoot: FC = ({ blockUpdateHandler, sidebarBlockRender, blockRender, + enableLeftDrag, + enableRightDrag, + enableReorder, }) => { const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); @@ -62,13 +63,13 @@ export const ChartViewRoot: FC = ({ const [blocksSidebarView, setBlocksSidebarView] = useState(false); // blocks state management starts - const [chartBlocks, setChartBlocks] = useState(null); + const [chartBlocks, setChartBlocks] = useState(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 = ({ 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 = ({ 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 = ({ 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 = ({
*/} {/* chart header */} -
+
{/*
setBlocksSidebarView(() => !blocksSidebarView)} @@ -301,8 +304,8 @@ export const ChartViewRoot: FC = ({
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 ? ( @@ -325,6 +328,10 @@ export const ChartViewRoot: FC = ({ blocks={chartBlocks} sidebarBlockRender={sidebarBlockRender} blockRender={blockRender} + blockUpdateHandler={blockUpdateHandler} + enableLeftDrag={enableLeftDrag} + enableRightDrag={enableRightDrag} + enableReorder={enableReorder} /> )} diff --git a/apps/app/components/gantt-chart/chart/month.tsx b/apps/app/components/gantt-chart/chart/month.tsx index 68e517960..b6c68b5d1 100644 --- a/apps/app/components/gantt-chart/chart/month.tsx +++ b/apps/app/components/gantt-chart/chart/month.tsx @@ -1,48 +1,55 @@ import { FC } from "react"; -// context + +// hooks import { useChart } from "../hooks"; +// types +import { IMonthBlock } from "../views"; export const MonthChartView: FC = () => { - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentViewData, renderView } = useChart(); + + const monthBlocks: IMonthBlock[] = renderView; return ( <> -
- {renderView && - renderView.length > 0 && - renderView.map((_itemRoot: any, _idxRoot: any) => ( -
+
+ {monthBlocks && + monthBlocks.length > 0 && + monthBlocks.map((block, _idxRoot) => ( +
- {_itemRoot?.title} + {block?.title}
-
- {_itemRoot.children && - _itemRoot.children.length > 0 && - _itemRoot.children.map((_item: any, _idx: any) => ( +
+ {block?.children && + block?.children.length > 0 && + block?.children.map((monthDay, _idx) => (
-
{_item.title}
+
{monthDay?.title}
- {_item?.today && ( -
+ {monthDay?.today && ( +
)}
diff --git a/apps/app/components/gantt-chart/chart/quarter.tsx b/apps/app/components/gantt-chart/chart/quarter.tsx index 67605b6b5..abe56c83e 100644 --- a/apps/app/components/gantt-chart/chart/quarter.tsx +++ b/apps/app/components/gantt-chart/chart/quarter.tsx @@ -25,7 +25,7 @@ export const QuarterChartView: FC = () => {
= () => {
= () => {
+ 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 ?? ""), + })) + : []; diff --git a/apps/app/components/gantt-chart/helpers/draggable.tsx b/apps/app/components/gantt-chart/helpers/draggable.tsx index a5404cc8e..8a85a0dd3 100644 --- a/apps/app/components/gantt-chart/helpers/draggable.tsx +++ b/apps/app/components/gantt-chart/helpers/draggable.tsx @@ -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 = ({ + 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(null); + const resizableRef = useRef(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 (
- {children} + {enableLeftDrag && ( + <> +
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" + /> +
+ + )} + {React.cloneElement(children, { ref: resizableRef, ...provided.dragHandleProps })} + {enableRightDrag && ( + <> +
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" + /> +
+ + )}
); }; - -// 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 ( -//
-// {children} -//
-// ); -// }; diff --git a/apps/app/components/gantt-chart/helpers/index.ts b/apps/app/components/gantt-chart/helpers/index.ts new file mode 100644 index 000000000..c4c919ec0 --- /dev/null +++ b/apps/app/components/gantt-chart/helpers/index.ts @@ -0,0 +1 @@ +export * from "./block-structure"; diff --git a/apps/app/components/gantt-chart/hooks/block-update.tsx b/apps/app/components/gantt-chart/hooks/block-update.tsx new file mode 100644 index 000000000..d9d808b38 --- /dev/null +++ b/apps/app/components/gantt-chart/hooks/block-update.tsx @@ -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, + 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()); +}; diff --git a/apps/app/components/gantt-chart/index.ts b/apps/app/components/gantt-chart/index.ts index 1efe34c51..4520ee194 100644 --- a/apps/app/components/gantt-chart/index.ts +++ b/apps/app/components/gantt-chart/index.ts @@ -1 +1,5 @@ +export * from "./blocks"; +export * from "./helpers"; +export * from "./hooks"; export * from "./root"; +export * from "./types"; diff --git a/apps/app/components/gantt-chart/root.tsx b/apps/app/components/gantt-chart/root.tsx index c52bc55b0..077e8a896 100644 --- a/apps/app/components/gantt-chart/root.tsx +++ b/apps/app/components/gantt-chart/root.tsx @@ -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; blockRender: FC; + enableLeftDrag?: boolean; + enableRightDrag?: boolean; + enableReorder?: boolean; }; export const GanttChartRoot: FC = ({ @@ -22,6 +27,9 @@ export const GanttChartRoot: FC = ({ blockUpdateHandler, sidebarBlockRender, blockRender, + enableLeftDrag = true, + enableRightDrag = true, + enableReorder = true, }) => ( = ({ blockUpdateHandler={blockUpdateHandler} sidebarBlockRender={sidebarBlockRender} blockRender={blockRender} + enableLeftDrag={enableLeftDrag} + enableRightDrag={enableRightDrag} + enableReorder={enableReorder} /> ); diff --git a/apps/app/components/gantt-chart/types/index.ts b/apps/app/components/gantt-chart/types/index.ts index aa6ebe9da..645fd9c87 100644 --- a/apps/app/components/gantt-chart/types/index.ts +++ b/apps/app/components/gantt-chart/types/index.ts @@ -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; } diff --git a/apps/app/components/gantt-chart/views/month-view.ts b/apps/app/components/gantt-chart/views/month-view.ts index 7211a45eb..db21e372b 100644 --- a/apps/app/components/gantt-chart/views/month-view.ts +++ b/apps/app/components/gantt-chart/views/month-view.ts @@ -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; diff --git a/apps/app/components/gantt-chart/views/year-view.ts b/apps/app/components/gantt-chart/views/year-view.ts index 76edb0d57..82d397e97 100644 --- a/apps/app/components/gantt-chart/views/year-view.ts +++ b/apps/app/components/gantt-chart/views/year-view.ts @@ -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 }; }; diff --git a/apps/app/components/issues/gantt-chart.tsx b/apps/app/components/issues/gantt-chart.tsx index d8a54619f..4912183a8 100644 --- a/apps/app/components/issues/gantt-chart.tsx +++ b/apps/app/components/issues/gantt-chart.tsx @@ -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 = ({}) => { +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 = ({}) => {
); - // rendering issues on gantt card - const GanttBlockView = ({ data }: any) => ( - - -
- -
- {data?.name} -
-
- {data.infoToggle && ( - -
- - info - -
-
- )} -
- - ); - - // 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 (
+ updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) + } sidebarBlockRender={(data: any) => } - blockRender={(data: any) => } + blockRender={(data: any) => } + enableReorder={orderBy === "sort_order"} />
); diff --git a/apps/app/components/issues/label.tsx b/apps/app/components/issues/label.tsx index f3a7be9dd..c5a48d0ad 100644 --- a/apps/app/components/issues/label.tsx +++ b/apps/app/components/issues/label.tsx @@ -18,7 +18,7 @@ export const ViewIssueLabel: React.FC = ({ issue, maxRender = 1 }) => ( {issue.label_details.map((label, index) => (
@@ -35,7 +35,7 @@ export const ViewIssueLabel: React.FC = ({ issue, maxRender = 1 }) => ( ))} ) : ( -
+
= ({ 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(); diff --git a/apps/app/components/issues/my-issues/my-issues-view-options.tsx b/apps/app/components/issues/my-issues/my-issues-view-options.tsx index 35ed2295c..23d0d2535 100644 --- a/apps/app/components/issues/my-issues/my-issues-view-options.tsx +++ b/apps/app/components/issues/my-issues/my-issues-view-options.tsx @@ -120,7 +120,7 @@ export const MyIssuesViewOptions: React.FC = () => { {({ open }) => ( <> = ({ 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; diff --git a/apps/app/components/issues/view-select/assignee.tsx b/apps/app/components/issues/view-select/assignee.tsx index 0c9e9bd27..1026f240d 100644 --- a/apps/app/components/issues/view-select/assignee.tsx +++ b/apps/app/components/issues/view-select/assignee.tsx @@ -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 = ({ > {issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? (
- +
) : ( -
- +
+
)}
@@ -87,6 +87,7 @@ export const ViewAssigneeSelect: React.FC = ({ return ( { const newData = issue.assignees ?? []; diff --git a/apps/app/components/issues/view-select/priority.tsx b/apps/app/components/issues/view-select/priority.tsx index 5d3bcd089..f9872729c 100644 --- a/apps/app/components/issues/view-select/priority.tsx +++ b/apps/app/components/issues/view-select/priority.tsx @@ -67,14 +67,8 @@ export const ViewPrioritySelect: React.FC = ({ 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`} > = ({ 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" diff --git a/apps/app/components/issues/view-select/start-date.tsx b/apps/app/components/issues/view-select/start-date.tsx index ddec1a310..29110eadb 100644 --- a/apps/app/components/issues/view-select/start-date.tsx +++ b/apps/app/components/issues/view-select/start-date.tsx @@ -45,7 +45,7 @@ export const ViewStartDateSelect: React.FC = ({ >
{ partialUpdateIssue( diff --git a/apps/app/components/issues/view-select/state.tsx b/apps/app/components/issues/view-select/state.tsx index 7f5844697..460a11272 100644 --- a/apps/app/components/issues/view-select/state.tsx +++ b/apps/app/components/issues/view-select/state.tsx @@ -74,9 +74,9 @@ export const ViewStateSelect: React.FC = ({ position={tooltipPosition} >
- + {selectedOption && - getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)} + getStateGroupIcon(selectedOption.group, "14", "14", selectedOption.color)} {selectedOption?.name ?? "State"}
@@ -131,6 +131,7 @@ export const ViewStateSelect: React.FC = ({ disabled={isNotAllowed} onOpen={() => setFetchStates(true)} noChevron + selfPositioned={selfPositioned} /> ); }; diff --git a/apps/app/components/labels/create-label-modal.tsx b/apps/app/components/labels/create-label-modal.tsx index 7af86888d..190b9e832 100644 --- a/apps/app/components/labels/create-label-modal.tsx +++ b/apps/app/components/labels/create-label-modal.tsx @@ -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 = ({ watch, control, reset, + setValue, } = useForm({ defaultValues, }); + useEffect(() => { + if (isOpen) setValue("color", getRandomLabelColor()); + }, [setValue, isOpen]); + const onClose = () => { handleClose(); reset(defaultValues); @@ -156,6 +162,7 @@ export const CreateLabelModal: React.FC = ({ render={({ field: { value, onChange } }) => ( { onChange(value.hex); close(); diff --git a/apps/app/components/labels/create-update-label-inline.tsx b/apps/app/components/labels/create-update-label-inline.tsx index ca4fdf3dc..6306d14ca 100644 --- a/apps/app/components/labels/create-update-label-inline.tsx +++ b/apps/app/components/labels/create-update-label-inline.tsx @@ -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>; isUpdating: boolean; labelToUpdate: IIssueLabels | null; + onClose?: () => void; }; const defaultValues: Partial = { @@ -35,167 +37,180 @@ const defaultValues: Partial = { color: "rgb(var(--color-text-200))", }; -type Ref = HTMLDivElement; +export const CreateUpdateLabelInline = forwardRef( + function CreateUpdateLabelInline(props, ref) { + const { labelForm, setLabelForm, isUpdating, labelToUpdate, onClose } = props; -export const CreateUpdateLabelInline = forwardRef(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({ - defaultValues, - }); + const { + handleSubmit, + control, + register, + reset, + formState: { errors, isSubmitting }, + watch, + setValue, + } = useForm({ + defaultValues, + }); - const handleLabelCreate: SubmitHandler = 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( - PROJECT_ISSUE_LABELS(projectId as string), - (prevData) => [res, ...(prevData ?? [])], - false + const handleLabelCreate: SubmitHandler = async (formData) => { + if (!workspaceSlug || !projectId || isSubmitting) return; + + await issuesService + .createIssueLabel(workspaceSlug as string, projectId as string, formData, user) + .then((res) => { + mutate( + PROJECT_ISSUE_LABELS(projectId as string), + (prevData) => [res, ...(prevData ?? [])], + false + ); + handleClose(); + }); + }; + + const handleLabelUpdate: SubmitHandler = async (formData) => { + if (!workspaceSlug || !projectId || isSubmitting) return; + + await issuesService + .patchIssueLabel( + workspaceSlug as string, + projectId as string, + labelToUpdate?.id ?? "", + formData, + user + ) + .then(() => { + reset(defaultValues); + mutate( + 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 = 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( - 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 ( -
-
- - {({ open }) => ( - <> - - - - - - - ( - onChange(value.hex)} /> - )} - /> - - - - )} - -
-
- -
- { - reset(); - setLabelForm(false); - }} + return ( +
- Cancel - - {isUpdating ? ( - - {isSubmitting ? "Updating" : "Update"} - - ) : ( - - {isSubmitting ? "Adding" : "Add"} - - )} -
- ); -}); +
+ + {({ open }) => ( + <> + + + + + + + ( + onChange(value.hex)} + /> + )} + /> + + + + )} + +
+
+ +
+ handleClose()}>Cancel + {isUpdating ? ( + + {isSubmitting ? "Updating" : "Update"} + + ) : ( + + {isSubmitting ? "Adding" : "Add"} + + )} +
+ ); + } +); diff --git a/apps/app/components/modules/gantt-chart.tsx b/apps/app/components/modules/gantt-chart.tsx index 3ef46deb4..8ab8b6024 100644 --- a/apps/app/components/modules/gantt-chart.tsx +++ b/apps/app/components/modules/gantt-chart.tsx @@ -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 = ({}) => { 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 = ({}) => {
); - // rendering issues on gantt card - const GanttBlockView = ({ data }: any) => ( - - -
- -
- {data?.name} -
-
- {data.infoToggle && ( - -
- - info - -
-
- )} -
- - ); - - // 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 (
+ updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) + } sidebarBlockRender={(data: any) => } - blockRender={(data: any) => } + blockRender={(data: any) => } + enableReorder={orderBy === "sort_order"} />
); diff --git a/apps/app/components/modules/modules-list-gantt-chart.tsx b/apps/app/components/modules/modules-list-gantt-chart.tsx index b739f0b1e..2dd482d8b 100644 --- a/apps/app/components/modules/modules-list-gantt-chart.tsx +++ b/apps/app/components/modules/modules-list-gantt-chart.tsx @@ -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; }; -export const ModulesListGanttChartView: FC = ({ modules }) => { +export const ModulesListGanttChartView: FC = ({ 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 = ({ modules }) => {
); - // rendering issues on gantt card - const GanttBlockView = ({ data }: { data: IModule }) => ( - - -
s.value === data.status)?.color }} - /> - -
- {data?.name} -
-
-
- - ); + 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 = ({ modules }) => { title="Modules" loaderTitle="Modules" blocks={modules ? blockFormat(modules) : null} - blockUpdateHandler={handleUpdateDates} + blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)} sidebarBlockRender={(data: any) => } - blockRender={(data: any) => } + blockRender={(data: any) => } />
); diff --git a/apps/app/components/notifications/notification-popover.tsx b/apps/app/components/notifications/notification-popover.tsx index da9fb5668..39e4d8764 100644 --- a/apps/app/components/notifications/notification-popover.tsx +++ b/apps/app/components/notifications/notification-popover.tsx @@ -84,7 +84,7 @@ export const NotificationPopover = () => { disabled={!store?.theme?.sidebarCollapsed} > { {store?.theme?.sidebarCollapsed ? null : Notifications} {totalNotificationCount && totalNotificationCount > 0 ? ( - - {getNumberCount(totalNotificationCount)} - + store?.theme?.sidebarCollapsed ? ( + + ) : ( + + {getNumberCount(totalNotificationCount)} + + ) ) : null}
@@ -108,7 +112,7 @@ export const NotificationPopover = () => { leaveFrom="opacity-100 translate-y-0" leaveTo="opacity-0 translate-y-1" > - + { {({ open }) => ( <> { const [projectToDelete, setProjectToDelete] = useState(null); // router + const [isScrolled, setIsScrolled] = useState(false); + + const containerRef = useRef(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( @@ -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 ( <> { data={projectToDelete} user={user} /> -
+
{(provided) => ( @@ -147,7 +175,7 @@ export const ProjectSidebarList: FC = () => { Favorites { {({ open }) => ( <> {!store?.theme?.sidebarCollapsed && ( - - Projects - - +
+ + Projects + + + +
)} - - {orderedJoinedProjects.map((project, index) => ( - - {(provided, snapshot) => ( -
- handleDeleteProject(project)} - handleCopyText={() => handleCopyText(project.id)} - /> -
- )} -
- ))} -
+ + + {orderedJoinedProjects.map((project, index) => ( + + {(provided, snapshot) => ( +
+ handleDeleteProject(project)} + handleCopyText={() => handleCopyText(project.id)} + /> +
+ )} +
+ ))} +
+
{provided.placeholder} )} @@ -239,43 +287,7 @@ export const ProjectSidebarList: FC = () => { )}
- {otherProjects && otherProjects.length > 0 && ( - p.id === projectId) ? true : false} - > - {({ open }) => ( - <> - {!store?.theme?.sidebarCollapsed && ( - - Other Projects - - - )} - - {otherProjects?.map((project, index) => ( - handleDeleteProject(project)} - handleCopyText={() => handleCopyText(project.id)} - shortContextMenu - /> - ))} - - - )} - - )} + {allProjects && allProjects.length === 0 && (
)} + router.push(`/${workspaceSlug}/projects/${project?.id}/settings`)} + > +
+ + Settings +
+
)}
diff --git a/apps/app/components/ui/avatar.tsx b/apps/app/components/ui/avatar.tsx index cc68bc18b..8cd09fe6e 100644 --- a/apps/app/components/ui/avatar.tsx +++ b/apps/app/components/ui/avatar.tsx @@ -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 = ({ user, index, - height = "20px", - width = "20px", + height = "24px", + width = "24px", fontSize = "12px", }) => (
= ({ > {user && user.avatar && user.avatar !== "" ? (
= ({ > {user.display_name}
) : (
= ({ users, userIds, - length = 5, + length = 3, showLength = true, }) => { const router = useRouter(); @@ -88,7 +92,7 @@ export const AssigneesList: React.FC = ({ if ((users && users.length === 0) || (userIds && userIds.length === 0)) return ( -
+
No user
); @@ -100,7 +104,14 @@ export const AssigneesList: React.FC = ({ {users.slice(0, length).map((user, index) => ( ))} - {users.length > length ? +{users.length - length} : null} + {users.length > length ? ( +
+
+ + {users.length - length} +
+
+ ) : null} )} {userIds && ( @@ -112,7 +123,12 @@ export const AssigneesList: React.FC = ({ })} {showLength ? ( userIds.length > length ? ( - +{userIds.length - length} +
+
+ + {userIds.length - length} +
+
) : null ) : ( "" diff --git a/apps/app/components/views/gantt-chart.tsx b/apps/app/components/views/gantt-chart.tsx index 6687cb93f..630ffaca0 100644 --- a/apps/app/components/views/gantt-chart.tsx +++ b/apps/app/components/views/gantt-chart.tsx @@ -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 = ({}) => { 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 = ({}) => {
); - // rendering issues on gantt card - const GanttBlockView = ({ data }: any) => ( - - -
- -
- {data?.name} -
-
- {data.infoToggle && ( - -
- - info - -
-
- )} -
- - ); - - // 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 (
+ updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString()) + } sidebarBlockRender={(data: any) => } - blockRender={(data: any) => } + blockRender={(data: any) => } />
); diff --git a/apps/app/components/workspace/delete-workspace-modal.tsx b/apps/app/components/workspace/delete-workspace-modal.tsx index 54ddedc3b..b896a87e7 100644 --- a/apps/app/components/workspace/delete-workspace-modal.tsx +++ b/apps/app/components/workspace/delete-workspace-modal.tsx @@ -50,8 +50,10 @@ export const DeleteWorkspaceModal: React.FC = ({ isOpen, data, onClose, u const canDelete = confirmWorkspaceName === data?.name && confirmDeleteMyWorkspace; const handleClose = () => { - onClose(); setIsDeleteLoading(false); + setConfirmWorkspaceName(""); + setConfirmDeleteMyWorkspace(false); + onClose(); }; const handleDeletion = async () => { diff --git a/apps/app/components/workspace/index.ts b/apps/app/components/workspace/index.ts index d40b9d58e..8e354a718 100644 --- a/apps/app/components/workspace/index.ts +++ b/apps/app/components/workspace/index.ts @@ -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"; diff --git a/apps/app/components/workspace/sidebar-dropdown.tsx b/apps/app/components/workspace/sidebar-dropdown.tsx index e1c92282b..807a7de8b 100644 --- a/apps/app/components/workspace/sidebar-dropdown.tsx +++ b/apps/app/components/workspace/sidebar-dropdown.tsx @@ -146,13 +146,12 @@ export const WorkspaceSidebarDropdown = () => { >
-
{user?.display_name}
- Workspace + Workspace {workspaces ? ( -
+
{workspaces.length > 0 ? ( workspaces.map((workspace) => ( @@ -160,7 +159,7 @@ export const WorkspaceSidebarDropdown = () => { + + +
+ ); +}; diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index e7e97c738..97e8dfc90 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -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) => { diff --git a/apps/app/constants/label.ts b/apps/app/constants/label.ts new file mode 100644 index 000000000..220e56209 --- /dev/null +++ b/apps/app/constants/label.ts @@ -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]; +}; diff --git a/apps/app/constants/spreadsheet.ts b/apps/app/constants/spreadsheet.ts index 0dcab7220..df6cd5aef 100644 --- a/apps/app/constants/spreadsheet.ts +++ b/apps/app/constants/spreadsheet.ts @@ -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", diff --git a/apps/app/hooks/gantt-chart/cycle-issues-view.tsx b/apps/app/hooks/gantt-chart/cycle-issues-view.tsx index 1782ca339..25baf0d3e 100644 --- a/apps/app/hooks/gantt-chart/cycle-issues-view.tsx +++ b/apps/app/hooks/gantt-chart/cycle-issues-view.tsx @@ -2,6 +2,8 @@ import useSWR from "swr"; // services import cyclesService from "services/cycles.service"; +// hooks +import useIssuesView from "hooks/use-issues-view"; // fetch-keys import { CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys"; @@ -10,15 +12,27 @@ const useGanttChartCycleIssues = ( projectId: string | undefined, cycleId: string | undefined ) => { + const { orderBy, filters, showSubIssues } = useIssuesView(); + + const params: any = { + order_by: orderBy, + type: filters?.type ? filters?.type : undefined, + sub_issue: showSubIssues, + start_target_date: true, + }; + // all issues under the workspace and project const { data: ganttIssues, mutate: mutateGanttIssues } = useSWR( - workspaceSlug && projectId && cycleId ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString()) : null, + workspaceSlug && projectId && cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) + : null, workspaceSlug && projectId && cycleId ? () => cyclesService.getCycleIssuesWithParams( workspaceSlug.toString(), projectId.toString(), - cycleId.toString() + cycleId.toString(), + params ) : null ); diff --git a/apps/app/hooks/gantt-chart/issue-view.tsx b/apps/app/hooks/gantt-chart/issue-view.tsx index bcdb0fca4..c7ffa0ffe 100644 --- a/apps/app/hooks/gantt-chart/issue-view.tsx +++ b/apps/app/hooks/gantt-chart/issue-view.tsx @@ -2,15 +2,27 @@ import useSWR from "swr"; // services import issuesService from "services/issues.service"; +// hooks +import useIssuesView from "hooks/use-issues-view"; // fetch-keys import { PROJECT_ISSUES_LIST_WITH_PARAMS } from "constants/fetch-keys"; const useGanttChartIssues = (workspaceSlug: string | undefined, projectId: string | undefined) => { + const { orderBy, filters, showSubIssues } = useIssuesView(); + + const params: any = { + order_by: orderBy, + type: filters?.type ? filters?.type : undefined, + sub_issue: showSubIssues, + start_target_date: true, + }; + // all issues under the workspace and project const { data: ganttIssues, mutate: mutateGanttIssues } = useSWR( - workspaceSlug && projectId ? PROJECT_ISSUES_LIST_WITH_PARAMS(projectId) : null, + workspaceSlug && projectId ? PROJECT_ISSUES_LIST_WITH_PARAMS(projectId, params) : null, workspaceSlug && projectId - ? () => issuesService.getIssuesWithParams(workspaceSlug.toString(), projectId.toString()) + ? () => + issuesService.getIssuesWithParams(workspaceSlug.toString(), projectId.toString(), params) : null ); diff --git a/apps/app/hooks/gantt-chart/module-issues-view.tsx b/apps/app/hooks/gantt-chart/module-issues-view.tsx index baf995944..ca686f4e0 100644 --- a/apps/app/hooks/gantt-chart/module-issues-view.tsx +++ b/apps/app/hooks/gantt-chart/module-issues-view.tsx @@ -2,6 +2,8 @@ import useSWR from "swr"; // services import modulesService from "services/modules.service"; +// hooks +import useIssuesView from "hooks/use-issues-view"; // fetch-keys import { MODULE_ISSUES_WITH_PARAMS } from "constants/fetch-keys"; @@ -10,15 +12,27 @@ const useGanttChartModuleIssues = ( projectId: string | undefined, moduleId: string | undefined ) => { + const { orderBy, filters, showSubIssues } = useIssuesView(); + + const params: any = { + order_by: orderBy, + type: filters?.type ? filters?.type : undefined, + sub_issue: showSubIssues, + start_target_date: true, + }; + // all issues under the workspace and project const { data: ganttIssues, mutate: mutateGanttIssues } = useSWR( - workspaceSlug && projectId && moduleId ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString()) : null, + workspaceSlug && projectId && moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) + : null, workspaceSlug && projectId && moduleId ? () => modulesService.getModuleIssuesWithParams( workspaceSlug.toString(), projectId.toString(), - moduleId.toString() + moduleId.toString(), + params ) : null ); diff --git a/apps/app/hooks/gantt-chart/view-issues-view.tsx b/apps/app/hooks/gantt-chart/view-issues-view.tsx index 7fc138570..b66b35128 100644 --- a/apps/app/hooks/gantt-chart/view-issues-view.tsx +++ b/apps/app/hooks/gantt-chart/view-issues-view.tsx @@ -17,14 +17,15 @@ const useGanttChartViewIssues = ( // all issues under the view const { data: ganttIssues, mutate: mutateGanttIssues } = useSWR( - workspaceSlug && projectId && viewId ? VIEW_ISSUES(viewId.toString(), viewGanttParams) : null, + workspaceSlug && projectId && viewId + ? VIEW_ISSUES(viewId.toString(), { ...viewGanttParams, start_target_date: true }) + : null, workspaceSlug && projectId && viewId ? () => - issuesService.getIssuesWithParams( - workspaceSlug.toString(), - projectId.toString(), - viewGanttParams - ) + issuesService.getIssuesWithParams(workspaceSlug.toString(), projectId.toString(), { + ...viewGanttParams, + start_target_date: true, + }) : null ); diff --git a/apps/app/hooks/my-issues/use-my-issues.tsx b/apps/app/hooks/my-issues/use-my-issues.tsx index 6d39e548f..aae05bc84 100644 --- a/apps/app/hooks/my-issues/use-my-issues.tsx +++ b/apps/app/hooks/my-issues/use-my-issues.tsx @@ -52,8 +52,23 @@ const useMyIssues = (workspaceSlug: string | undefined) => { allIssues: myIssues, }; + if (groupBy === "state_detail.group") { + return myIssues + ? Object.assign( + { + backlog: [], + unstarted: [], + started: [], + completed: [], + cancelled: [], + }, + myIssues + ) + : undefined; + } + return myIssues; - }, [myIssues]); + }, [groupBy, myIssues]); const isEmpty = Object.values(groupedIssues ?? {}).every((group) => group.length === 0) || diff --git a/apps/app/layouts/app-layout/app-sidebar.tsx b/apps/app/layouts/app-layout/app-sidebar.tsx index 60ef645cf..04cc8393a 100644 --- a/apps/app/layouts/app-layout/app-sidebar.tsx +++ b/apps/app/layouts/app-layout/app-sidebar.tsx @@ -5,6 +5,7 @@ import { WorkspaceHelpSection, WorkspaceSidebarDropdown, WorkspaceSidebarMenu, + WorkspaceSidebarQuickAction, } from "components/workspace"; import { ProjectSidebarList } from "components/project"; // mobx react lite @@ -24,12 +25,14 @@ const Sidebar: React.FC = observer(({ toggleSidebar, setToggleSide return (
+ diff --git a/apps/app/pages/[workspaceSlug]/me/profile/index.tsx b/apps/app/pages/[workspaceSlug]/me/profile/index.tsx index 71c9cd094..c91776f55 100644 --- a/apps/app/pages/[workspaceSlug]/me/profile/index.tsx +++ b/apps/app/pages/[workspaceSlug]/me/profile/index.tsx @@ -12,7 +12,7 @@ import useToast from "hooks/use-toast"; import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; import SettingsNavbar from "layouts/settings-navbar"; // components -import { ImageUploadModal } from "components/core"; +import { ImagePickerPopover, ImageUploadModal } from "components/core"; // ui import { CustomSelect, DangerButton, Input, SecondaryButton, Spinner } from "components/ui"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; @@ -26,6 +26,7 @@ import { USER_ROLES } from "constants/workspace"; const defaultValues: Partial = { avatar: "", + cover_image: "", first_name: "", last_name: "", email: "", @@ -68,6 +69,7 @@ const Profile: NextPage = () => { first_name: formData.first_name, last_name: formData.last_name, avatar: formData.avatar, + cover_image: formData.cover_image, role: formData.role, display_name: formData.display_name, }; @@ -202,6 +204,40 @@ const Profile: NextPage = () => {
+
+
+

Cover Photo

+

+ Select your cover photo from the given library. +

+
+
+
+
+ {myProfile?.name +
+ { + setValue("cover_image", imageUrl); + }} + value={ + watch("cover_image") ?? + "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab" + } + /> +
+
+
+
+

Full Name

diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx index 91da78757..cb7b1fd33 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/index.tsx @@ -63,7 +63,7 @@ const ProjectIssues: NextPage = () => { setAnalyticsModal(true)} - className="!py-1.5 rounded-md font-normal text-custom-sidebar-text-200 border-custom-sidebar-border-200 hover:text-custom-text-100 hover:bg-custom-sidebar-background-90" + className="!py-1.5 rounded-md font-normal text-custom-sidebar-text-200 border-custom-border-200 hover:text-custom-text-100 hover:bg-custom-sidebar-background-90" outline > Analytics @@ -72,7 +72,7 @@ const ProjectIssues: NextPage = () => { Inbox @@ -97,6 +97,7 @@ const ProjectIssues: NextPage = () => {
} + bg="secondary" > setAnalyticsModal(false)} />
diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx index fb0811ffc..98f678d5d 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx @@ -50,7 +50,7 @@ const ProjectModules: NextPage = () => { : null ); - const { data: modules } = useSWR( + const { data: modules, mutate: mutateModules } = useSWR( workspaceSlug && projectId ? MODULE_LIST(projectId as string) : null, workspaceSlug && projectId ? () => modulesService.getModules(workspaceSlug as string, projectId as string) @@ -139,7 +139,9 @@ const ProjectModules: NextPage = () => {
)} - {modulesView === "gantt_chart" && } + {modulesView === "gantt_chart" && ( + + )}
) : ( { : null ); + const { data: memberDetails, error } = useSWR( + workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null, + workspaceSlug && projectId + ? () => projectService.projectMemberMe(workspaceSlug.toString(), projectId.toString()) + : null + ); + const { register, handleSubmit, @@ -157,6 +164,8 @@ const GeneralSettings: NextPage = () => { const currentNetwork = NETWORK_CHOICES.find((n) => n.key === projectDetails?.network); + const isAdmin = memberDetails?.role === 20; + return ( {
{projectDetails ? ( - + {isSubmitting ? "Updating Project..." : "Update Project"} ) : ( @@ -364,32 +373,34 @@ const GeneralSettings: NextPage = () => { )}
-
-
-

Danger Zone

-

- The danger zone of the project delete page is a critical area that requires careful - consideration and attention. When deleting a project, all of the data and resources - within that project will be permanently removed and cannot be recovered. -

+ {memberDetails?.role === 20 && ( +
+
+

Danger Zone

+

+ The danger zone of the project delete page is a critical area that requires + careful consideration and attention. When deleting a project, all of the data and + resources within that project will be permanently removed and cannot be recovered. +

+
+
+ {projectDetails ? ( +
+ setSelectedProject(projectDetails.id ?? null)} + outline + > + Delete Project + +
+ ) : ( + + + + )} +
-
- {projectDetails ? ( -
- setSelectedProject(projectDetails.id ?? null)} - outline - > - Delete Project - -
- ) : ( - - - - )} -
-
+ )}
diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx index 3f4495ef5..dc845da68 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/labels.tsx @@ -133,6 +133,11 @@ const LabelsSettings: NextPage = () => { setLabelForm={setLabelForm} isUpdating={isUpdating} labelToUpdate={labelToUpdate} + onClose={() => { + setLabelForm(false); + setIsUpdating(false); + setLabelToUpdate(null); + }} ref={scrollToRef} /> )} diff --git a/apps/app/pages/[workspaceSlug]/projects/index.tsx b/apps/app/pages/[workspaceSlug]/projects/index.tsx index 6c60d4abd..4dd6a234e 100644 --- a/apps/app/pages/[workspaceSlug]/projects/index.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/index.tsx @@ -16,7 +16,7 @@ import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; import { JoinProjectModal } from "components/project/join-project-modal"; import { DeleteProjectModal, SingleProjectCard } from "components/project"; // ui -import { EmptyState, Loader, PrimaryButton } from "components/ui"; +import { EmptyState, Icon, Loader, PrimaryButton } from "components/ui"; import { Breadcrumbs, BreadcrumbItem } from "components/breadcrumbs"; // icons import { PlusIcon } from "@heroicons/react/24/outline"; @@ -34,6 +34,8 @@ const ProjectsPage: NextPage = () => { const router = useRouter(); const { workspaceSlug } = router.query; + const [query, setQuery] = useState(""); + const { user } = useUserAuth(); // context data const { activeWorkspace } = useWorkspaces(); @@ -42,6 +44,15 @@ const ProjectsPage: NextPage = () => { const [deleteProject, setDeleteProject] = useState(null); const [selectedProjectToJoin, setSelectedProjectToJoin] = useState(null); + const filteredProjectList = + query === "" + ? projects + : projects?.filter( + (project) => + project.name.toLowerCase().includes(query.toLowerCase()) || + project.identifier.toLowerCase().includes(query.toLowerCase()) + ); + return ( { } right={ - { - const e = new KeyboardEvent("keydown", { key: "p" }); - document.dispatchEvent(e); - }} - > - - Add Project - +
+
+ + setQuery(e.target.value)} + placeholder="Search" + /> +
+ + { + const e = new KeyboardEvent("keydown", { key: "p" }); + document.dispatchEvent(e); + }} + > + + Add Project + +
} > { data={projects?.find((item) => item.id === deleteProject) ?? null} user={user} /> - {projects ? ( + {filteredProjectList ? (
- {projects.length > 0 ? ( + {filteredProjectList.length > 0 ? (
- {projects.map((project) => ( + {filteredProjectList.map((project) => ( { const { user } = useUserAuth(); + const { data: memberDetails } = useSWR( + workspaceSlug ? WORKSPACE_MEMBERS_ME(workspaceSlug.toString()) : null, + workspaceSlug ? () => workspaceService.workspaceMemberMe(workspaceSlug.toString()) : null + ); + const { setToastAlert } = useToast(); const { data: activeWorkspace } = useSWR( @@ -142,6 +147,8 @@ const WorkspaceSettings: NextPage = () => { }); }; + const isAdmin = memberDetails?.role === 20; + return ( {
- + {isSubmitting ? "Updating..." : "Update Workspace"}
-
-
-

Danger Zone

-

- The danger zone of the workspace delete page is a critical area that requires - careful consideration and attention. When deleting a workspace, all of the data - and resources within that workspace will be permanently removed and cannot be - recovered. -

+ {memberDetails?.role === 20 && ( +
+
+

Danger Zone

+

+ The danger zone of the workspace delete page is a critical area that requires + careful consideration and attention. When deleting a workspace, all of the data + and resources within that workspace will be permanently removed and cannot be + recovered. +

+
+
+ setIsOpen(true)} outline> + Delete the workspace + +
-
- setIsOpen(true)} outline> - Delete the workspace - -
-
+ )}
) : (
diff --git a/apps/app/services/modules.service.ts b/apps/app/services/modules.service.ts index ec4165c9b..357b92fa3 100644 --- a/apps/app/services/modules.service.ts +++ b/apps/app/services/modules.service.ts @@ -75,7 +75,7 @@ class ProjectIssuesServices extends APIService { workspaceSlug: string, projectId: string, moduleId: string, - data: any, + data: Partial, user: ICurrentUserResponse | undefined ): Promise { return this.patch( @@ -127,7 +127,7 @@ class ProjectIssuesServices extends APIService { workspaceSlug: string, projectId: string, moduleId: string, - queries?: Partial + queries?: any ): Promise< | IIssue[] | { diff --git a/apps/app/types/analytics.d.ts b/apps/app/types/analytics.d.ts index 94afb2c02..651596f19 100644 --- a/apps/app/types/analytics.d.ts +++ b/apps/app/types/analytics.d.ts @@ -69,6 +69,7 @@ export interface IDefaultAnalyticsUser { assignees__first_name: string; assignees__last_name: string; assignees__display_name: string; + assignees__id: string; count: number; } @@ -80,6 +81,7 @@ export interface IDefaultAnalyticsResponse { created_by__first_name: string; created_by__last_name: string; created_by__display_name: string; + created_by__id: string; count: number; }[]; open_estimate_sum: number; diff --git a/apps/app/types/cycles.d.ts b/apps/app/types/cycles.d.ts index df358b7a9..955e82222 100644 --- a/apps/app/types/cycles.d.ts +++ b/apps/app/types/cycles.d.ts @@ -29,6 +29,7 @@ export interface ICycle { owned_by: IUser; project: string; project_detail: IProjectLite; + sort_order: number; start_date: string | null; started_issues: number; total_issues: number; diff --git a/apps/app/types/modules.d.ts b/apps/app/types/modules.d.ts index 96afcff48..eefd42788 100644 --- a/apps/app/types/modules.d.ts +++ b/apps/app/types/modules.d.ts @@ -43,6 +43,7 @@ export interface IModule { name: string; project: string; project_detail: IProjectLite; + sort_order: number; start_date: string | null; started_issues: number; status: "backlog" | "planned" | "in-progress" | "paused" | "completed" | "cancelled" | null; diff --git a/apps/app/types/users.d.ts b/apps/app/types/users.d.ts index 68f0b0c78..c23512ecf 100644 --- a/apps/app/types/users.d.ts +++ b/apps/app/types/users.d.ts @@ -11,6 +11,7 @@ import { export interface IUser { avatar: string; + cover_image: string | null; created_at: readonly Date; created_location: readonly string; date_joined: readonly Date; diff --git a/apps/space/.env.example b/apps/space/.env.example index 7cecf3739..4fb0e4df6 100644 --- a/apps/space/.env.example +++ b/apps/space/.env.example @@ -1 +1 @@ -NEXT_PUBLIC_VERCEL_ENV=local \ No newline at end of file +NEXT_PUBLIC_API_BASE_URL='' \ No newline at end of file diff --git a/apps/space/app/[workspace_slug]/[project_slug]/layout.tsx b/apps/space/app/[workspace_slug]/[project_slug]/layout.tsx new file mode 100644 index 000000000..8cc3ee8c8 --- /dev/null +++ b/apps/space/app/[workspace_slug]/[project_slug]/layout.tsx @@ -0,0 +1,33 @@ +"use client"; + +// next imports +import Link from "next/link"; +import Image from "next/image"; +// components +import IssueNavbar from "components/issues/navbar"; +import IssueFilter from "components/issues/filters-render"; + +const RootLayout = ({ children }: { children: React.ReactNode }) => ( +
+
+ +
+ {/*
+ +
*/} +
{children}
+ +
+ +
+ plane logo +
+
+ Powered by Plane Deploy +
+ +
+
+); + +export default RootLayout; diff --git a/apps/space/app/[workspace_slug]/[project_slug]/page.tsx b/apps/space/app/[workspace_slug]/[project_slug]/page.tsx new file mode 100644 index 000000000..0aa9b164d --- /dev/null +++ b/apps/space/app/[workspace_slug]/[project_slug]/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect } from "react"; +// next imports +import { useRouter, useParams, useSearchParams } from "next/navigation"; +// mobx +import { observer } from "mobx-react-lite"; +// components +import { IssueListView } from "components/issues/board-views/list"; +import { IssueKanbanView } from "components/issues/board-views/kanban"; +import { IssueCalendarView } from "components/issues/board-views/calendar"; +import { IssueSpreadsheetView } from "components/issues/board-views/spreadsheet"; +import { IssueGanttView } from "components/issues/board-views/gantt"; +// mobx store +import { RootStore } from "store/root"; +import { useMobxStore } from "lib/mobx/store-provider"; +// types +import { TIssueBoardKeys } from "store/types"; + +const WorkspaceProjectPage = observer(() => { + const store: RootStore = useMobxStore(); + + const router = useRouter(); + const routerParams = useParams(); + const routerSearchparams = useSearchParams(); + + const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string }; + const board = routerSearchparams.get("board") as TIssueBoardKeys | ""; + + // updating default board view when we are in the issues page + useEffect(() => { + if (workspace_slug && project_slug) { + if (!board) { + store.issue.setCurrentIssueBoardView("list"); + router.replace(`/${workspace_slug}/${project_slug}?board=${store?.issue?.currentIssueBoardView}`); + } else { + if (board != store?.issue?.currentIssueBoardView) store.issue.setCurrentIssueBoardView(board); + } + } + }, [workspace_slug, project_slug, board, router, store?.issue]); + + useEffect(() => { + if (workspace_slug && project_slug) { + store?.project?.getProjectSettingsAsync(workspace_slug, project_slug); + store?.issue?.getIssuesAsync(workspace_slug, project_slug); + } + }, [workspace_slug, project_slug, store?.project, store?.issue]); + + return ( +
+ {store?.issue?.loader && !store.issue.issues ? ( +
Loading...
+ ) : ( + <> + {store?.issue?.error ? ( +
Something went wrong.
+ ) : ( + store?.issue?.currentIssueBoardView && ( + <> + {store?.issue?.currentIssueBoardView === "list" && ( +
+
+ +
+
+ )} + {store?.issue?.currentIssueBoardView === "kanban" && ( +
+ +
+ )} + {store?.issue?.currentIssueBoardView === "calendar" && } + {store?.issue?.currentIssueBoardView === "spreadsheet" && } + {store?.issue?.currentIssueBoardView === "gantt" && } + + ) + )} + + )} +
+ ); +}); + +export default WorkspaceProjectPage; diff --git a/apps/space/app/[workspace_project_slug]/page.tsx b/apps/space/app/[workspace_slug]/page.tsx similarity index 60% rename from apps/space/app/[workspace_project_slug]/page.tsx rename to apps/space/app/[workspace_slug]/page.tsx index 638d36e77..c35662f5a 100644 --- a/apps/space/app/[workspace_project_slug]/page.tsx +++ b/apps/space/app/[workspace_slug]/page.tsx @@ -1,9 +1,7 @@ -import React from "react"; +"use client"; const WorkspaceProjectPage = () => ( -
- Plane Workspace project Space -
+
Plane Workspace Space
); export default WorkspaceProjectPage; diff --git a/apps/space/app/layout.tsx b/apps/space/app/layout.tsx index 5c7de32ff..b63f748e8 100644 --- a/apps/space/app/layout.tsx +++ b/apps/space/app/layout.tsx @@ -1,10 +1,18 @@ +"use client"; + // root styles import "styles/globals.css"; +// mobx store provider +import { MobxStoreProvider } from "lib/mobx/store-provider"; +import MobxStoreInit from "lib/mobx/store-init"; const RootLayout = ({ children }: { children: React.ReactNode }) => ( -
{children}
+ + +
{children}
+
); diff --git a/apps/space/app/page.tsx b/apps/space/app/page.tsx index bbfe9c3ea..c1b2926b3 100644 --- a/apps/space/app/page.tsx +++ b/apps/space/app/page.tsx @@ -1,3 +1,5 @@ +"use client"; + import React from "react"; const HomePage = () => ( diff --git a/apps/space/components/icons/index.ts b/apps/space/components/icons/index.ts new file mode 100644 index 000000000..5f23e0f3a --- /dev/null +++ b/apps/space/components/icons/index.ts @@ -0,0 +1,5 @@ +export * from "./issue-group/backlog-state-icon"; +export * from "./issue-group/unstarted-state-icon"; +export * from "./issue-group/started-state-icon"; +export * from "./issue-group/completed-state-icon"; +export * from "./issue-group/cancelled-state-icon"; diff --git a/apps/space/components/icons/issue-group/backlog-state-icon.tsx b/apps/space/components/icons/issue-group/backlog-state-icon.tsx new file mode 100644 index 000000000..f2f62d24a --- /dev/null +++ b/apps/space/components/icons/issue-group/backlog-state-icon.tsx @@ -0,0 +1,23 @@ +import React from "react"; +// types +import type { Props } from "../types"; +// constants +import { issueGroupColors } from "constants/data"; + +export const BacklogStateIcon: React.FC = ({ + width = "14", + height = "14", + className, + color = issueGroupColors["backlog"], +}) => ( + + + +); diff --git a/apps/space/components/icons/issue-group/cancelled-state-icon.tsx b/apps/space/components/icons/issue-group/cancelled-state-icon.tsx new file mode 100644 index 000000000..e244c191a --- /dev/null +++ b/apps/space/components/icons/issue-group/cancelled-state-icon.tsx @@ -0,0 +1,74 @@ +import React from "react"; +// types +import type { Props } from "../types"; +// constants +import { issueGroupColors } from "constants/data"; + +export const CancelledStateIcon: React.FC = ({ + width = "14", + height = "14", + className, + color = issueGroupColors["cancelled"], +}) => ( + + + + + + + + + + + + + +); diff --git a/apps/space/components/icons/issue-group/completed-state-icon.tsx b/apps/space/components/icons/issue-group/completed-state-icon.tsx new file mode 100644 index 000000000..417ebbf3f --- /dev/null +++ b/apps/space/components/icons/issue-group/completed-state-icon.tsx @@ -0,0 +1,65 @@ +import React from "react"; +// types +import type { Props } from "../types"; +// constants +import { issueGroupColors } from "constants/data"; + +export const CompletedStateIcon: React.FC = ({ + width = "14", + height = "14", + className, + color = issueGroupColors["completed"], +}) => ( + + + + + + + + + + + + +); diff --git a/apps/space/components/icons/issue-group/started-state-icon.tsx b/apps/space/components/icons/issue-group/started-state-icon.tsx new file mode 100644 index 000000000..4ebd1771f --- /dev/null +++ b/apps/space/components/icons/issue-group/started-state-icon.tsx @@ -0,0 +1,73 @@ +import React from "react"; +// types +import type { Props } from "../types"; +// constants +import { issueGroupColors } from "constants/data"; + +export const StartedStateIcon: React.FC = ({ + width = "14", + height = "14", + className, + color = issueGroupColors["started"], +}) => ( + + + + + + + + + + + + +); diff --git a/apps/space/components/icons/issue-group/unstarted-state-icon.tsx b/apps/space/components/icons/issue-group/unstarted-state-icon.tsx new file mode 100644 index 000000000..f79bc00fc --- /dev/null +++ b/apps/space/components/icons/issue-group/unstarted-state-icon.tsx @@ -0,0 +1,55 @@ +import React from "react"; +// types +import type { Props } from "../types"; +// constants +import { issueGroupColors } from "constants/data"; + +export const UnstartedStateIcon: React.FC = ({ + width = "14", + height = "14", + className, + color = issueGroupColors["unstarted"], +}) => ( + + + + + + + + + + +); diff --git a/apps/space/components/icons/types.d.ts b/apps/space/components/icons/types.d.ts new file mode 100644 index 000000000..f82a18147 --- /dev/null +++ b/apps/space/components/icons/types.d.ts @@ -0,0 +1,6 @@ +export type Props = { + width?: string | number; + height?: string | number; + color?: string; + className?: string; +}; diff --git a/apps/space/components/issues/board-views/block-due-date.tsx b/apps/space/components/issues/board-views/block-due-date.tsx new file mode 100644 index 000000000..6d3cc3cc0 --- /dev/null +++ b/apps/space/components/issues/board-views/block-due-date.tsx @@ -0,0 +1,32 @@ +"use client"; + +// helpers +import { renderDateFormat } from "constants/helpers"; + +export const findHowManyDaysLeft = (date: string | Date) => { + const today = new Date(); + const eventDate = new Date(date); + const timeDiff = Math.abs(eventDate.getTime() - today.getTime()); + return Math.ceil(timeDiff / (1000 * 3600 * 24)); +}; + +const validDate = (date: any, state: string): string => { + if (date === null || ["backlog", "unstarted", "cancelled"].includes(state)) + return `bg-gray-500/10 text-gray-500 border-gray-500/50`; + else { + const today = new Date(); + const dueDate = new Date(date); + + if (dueDate < today) return `bg-red-500/10 text-red-500 border-red-500/50`; + else return `bg-green-500/10 text-green-500 border-green-500/50`; + } +}; + +export const IssueBlockDueDate = ({ due_date, state }: any) => ( +
+ {renderDateFormat(due_date)} +
+); diff --git a/apps/space/components/issues/board-views/block-labels.tsx b/apps/space/components/issues/board-views/block-labels.tsx new file mode 100644 index 000000000..90cc1629c --- /dev/null +++ b/apps/space/components/issues/board-views/block-labels.tsx @@ -0,0 +1,17 @@ +"use client"; + +export const IssueBlockLabels = ({ labels }: any) => ( +
+ {labels && + labels.length > 0 && + labels.map((_label: any) => ( +
+
+
{_label?.name}
+
+ ))} +
+); diff --git a/apps/space/components/issues/board-views/block-priority.tsx b/apps/space/components/issues/board-views/block-priority.tsx new file mode 100644 index 000000000..61ca50765 --- /dev/null +++ b/apps/space/components/issues/board-views/block-priority.tsx @@ -0,0 +1,17 @@ +"use client"; + +// types +import { TIssuePriorityKey } from "store/types/issue"; +// constants +import { issuePriorityFilter } from "constants/data"; + +export const IssueBlockPriority = ({ priority }: { priority: TIssuePriorityKey | null }) => { + const priority_detail = priority != null ? issuePriorityFilter(priority) : null; + + if (priority_detail === null) return <>; + return ( +
+ {priority_detail?.icon} +
+ ); +}; diff --git a/apps/space/components/issues/board-views/block-state.tsx b/apps/space/components/issues/board-views/block-state.tsx new file mode 100644 index 000000000..87cd65938 --- /dev/null +++ b/apps/space/components/issues/board-views/block-state.tsx @@ -0,0 +1,18 @@ +"use client"; + +// constants +import { issueGroupFilter } from "constants/data"; + +export const IssueBlockState = ({ state }: any) => { + const stateGroup = issueGroupFilter(state.group); + + if (stateGroup === null) return <>; + return ( +
+ +
{state?.name}
+
+ ); +}; diff --git a/apps/space/components/issues/board-views/calendar/index.tsx b/apps/space/components/issues/board-views/calendar/index.tsx new file mode 100644 index 000000000..0edeca96c --- /dev/null +++ b/apps/space/components/issues/board-views/calendar/index.tsx @@ -0,0 +1 @@ +export const IssueCalendarView = () =>
; diff --git a/apps/space/components/issues/board-views/gantt/index.tsx b/apps/space/components/issues/board-views/gantt/index.tsx new file mode 100644 index 000000000..5da924b2c --- /dev/null +++ b/apps/space/components/issues/board-views/gantt/index.tsx @@ -0,0 +1 @@ +export const IssueGanttView = () =>
; diff --git a/apps/space/components/issues/board-views/kanban/block.tsx b/apps/space/components/issues/board-views/kanban/block.tsx new file mode 100644 index 000000000..304e05612 --- /dev/null +++ b/apps/space/components/issues/board-views/kanban/block.tsx @@ -0,0 +1,57 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// components +import { IssueBlockPriority } from "components/issues/board-views/block-priority"; +import { IssueBlockState } from "components/issues/board-views/block-state"; +import { IssueBlockLabels } from "components/issues/board-views/block-labels"; +import { IssueBlockDueDate } from "components/issues/board-views/block-due-date"; +// mobx hook +import { useMobxStore } from "lib/mobx/store-provider"; +// interfaces +import { IIssue } from "store/types/issue"; +import { RootStore } from "store/root"; + +export const IssueListBlock = ({ issue }: { issue: IIssue }) => { + const store: RootStore = useMobxStore(); + + return ( +
+ {/* id */} +
+ {store?.project?.project?.identifier}-{issue?.sequence_id} +
+ + {/* name */} +
{issue.name}
+ + {/* priority */} +
+ {issue?.priority && ( +
+ +
+ )} + {/* state */} + {issue?.state_detail && ( +
+ +
+ )} + {/* labels */} + {issue?.label_details && issue?.label_details.length > 0 && ( +
+ +
+ )} + {/* due date */} + {issue?.target_date && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/apps/space/components/issues/board-views/kanban/header.tsx b/apps/space/components/issues/board-views/kanban/header.tsx new file mode 100644 index 000000000..43c19f5f5 --- /dev/null +++ b/apps/space/components/issues/board-views/kanban/header.tsx @@ -0,0 +1,31 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// interfaces +import { IIssueState } from "store/types/issue"; +// constants +import { issueGroupFilter } from "constants/data"; +// mobx hook +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { + const store: RootStore = useMobxStore(); + + const stateGroup = issueGroupFilter(state.group); + + if (stateGroup === null) return <>; + + return ( +
+
+ +
+
{state?.name}
+
+ {store.issue.getCountOfIssuesByState(state.id)} +
+
+ ); +}); diff --git a/apps/space/components/issues/board-views/kanban/index.tsx b/apps/space/components/issues/board-views/kanban/index.tsx new file mode 100644 index 000000000..d716356ff --- /dev/null +++ b/apps/space/components/issues/board-views/kanban/index.tsx @@ -0,0 +1,44 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// components +import { IssueListHeader } from "components/issues/board-views/kanban/header"; +import { IssueListBlock } from "components/issues/board-views/kanban/block"; +// interfaces +import { IIssueState, IIssue } from "store/types/issue"; +// mobx hook +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +export const IssueKanbanView = observer(() => { + const store: RootStore = useMobxStore(); + + return ( +
+ {store?.issue?.states && + store?.issue?.states.length > 0 && + store?.issue?.states.map((_state: IIssueState) => ( +
+
+ +
+
+ {store.issue.getFilteredIssuesByState(_state.id) && + store.issue.getFilteredIssuesByState(_state.id).length > 0 ? ( +
+ {store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( + + ))} +
+ ) : ( +
+ No Issues are available. +
+ )} +
+
+ ))} +
+ ); +}); diff --git a/apps/space/components/issues/board-views/list/block.tsx b/apps/space/components/issues/board-views/list/block.tsx new file mode 100644 index 000000000..b9dfcc6ab --- /dev/null +++ b/apps/space/components/issues/board-views/list/block.tsx @@ -0,0 +1,59 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// components +import { IssueBlockPriority } from "components/issues/board-views/block-priority"; +import { IssueBlockState } from "components/issues/board-views/block-state"; +import { IssueBlockLabels } from "components/issues/board-views/block-labels"; +import { IssueBlockDueDate } from "components/issues/board-views/block-due-date"; +// mobx hook +import { useMobxStore } from "lib/mobx/store-provider"; +// interfaces +import { IIssue } from "store/types/issue"; +import { RootStore } from "store/root"; + +export const IssueListBlock = ({ issue }: { issue: IIssue }) => { + const store: RootStore = useMobxStore(); + + return ( +
+
+ {/* id */} +
+ {store?.project?.project?.identifier}-{issue?.sequence_id} +
+ {/* name */} +
{issue.name}
+
+ + {/* priority */} + {issue?.priority && ( +
+ +
+ )} + + {/* state */} + {issue?.state_detail && ( +
+ +
+ )} + + {/* labels */} + {issue?.label_details && issue?.label_details.length > 0 && ( +
+ +
+ )} + + {/* due date */} + {issue?.target_date && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/space/components/issues/board-views/list/header.tsx b/apps/space/components/issues/board-views/list/header.tsx new file mode 100644 index 000000000..e87cac6f7 --- /dev/null +++ b/apps/space/components/issues/board-views/list/header.tsx @@ -0,0 +1,31 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// interfaces +import { IIssueState } from "store/types/issue"; +// constants +import { issueGroupFilter } from "constants/data"; +// mobx hook +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { + const store: RootStore = useMobxStore(); + + const stateGroup = issueGroupFilter(state.group); + + if (stateGroup === null) return <>; + + return ( +
+
+ +
+
{state?.name}
+
+ {store.issue.getCountOfIssuesByState(state.id)} +
+
+ ); +}); diff --git a/apps/space/components/issues/board-views/list/index.tsx b/apps/space/components/issues/board-views/list/index.tsx new file mode 100644 index 000000000..7a7ec0de1 --- /dev/null +++ b/apps/space/components/issues/board-views/list/index.tsx @@ -0,0 +1,38 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// components +import { IssueListHeader } from "components/issues/board-views/list/header"; +import { IssueListBlock } from "components/issues/board-views/list/block"; +// interfaces +import { IIssueState, IIssue } from "store/types/issue"; +// mobx hook +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +export const IssueListView = observer(() => { + const store: RootStore = useMobxStore(); + + return ( + <> + {store?.issue?.states && + store?.issue?.states.length > 0 && + store?.issue?.states.map((_state: IIssueState) => ( +
+ + {store.issue.getFilteredIssuesByState(_state.id) && + store.issue.getFilteredIssuesByState(_state.id).length > 0 ? ( +
+ {store.issue.getFilteredIssuesByState(_state.id).map((_issue: IIssue) => ( + + ))} +
+ ) : ( +
No Issues are available.
+ )} +
+ ))} + + ); +}); diff --git a/apps/space/components/issues/board-views/spreadsheet/index.tsx b/apps/space/components/issues/board-views/spreadsheet/index.tsx new file mode 100644 index 000000000..45ebf2792 --- /dev/null +++ b/apps/space/components/issues/board-views/spreadsheet/index.tsx @@ -0,0 +1 @@ +export const IssueSpreadsheetView = () =>
; diff --git a/apps/space/components/issues/filters-render/date.tsx b/apps/space/components/issues/filters-render/date.tsx new file mode 100644 index 000000000..e01d0ae58 --- /dev/null +++ b/apps/space/components/issues/filters-render/date.tsx @@ -0,0 +1,38 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// mobx hook +import { useMobxStore } from "lib/mobx/store-provider"; + +const IssueDateFilter = observer(() => { + const store = useMobxStore(); + + return ( + <> +
+
Due Date
+
+ {/*
+
+ close +
+
Backlog
+
+ close +
+
*/} +
+
+ close +
+
+ + ); +}); + +export default IssueDateFilter; diff --git a/apps/space/components/issues/filters-render/index.tsx b/apps/space/components/issues/filters-render/index.tsx new file mode 100644 index 000000000..366ae1030 --- /dev/null +++ b/apps/space/components/issues/filters-render/index.tsx @@ -0,0 +1,40 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// components +import IssueStateFilter from "./state"; +import IssueLabelFilter from "./label"; +import IssuePriorityFilter from "./priority"; +import IssueDateFilter from "./date"; +// mobx hook +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +const IssueFilter = observer(() => { + const store: RootStore = useMobxStore(); + + const clearAllFilters = () => {}; + + return ( +
+ {/* state */} + {store?.issue?.states && } + {/* labels */} + {store?.issue?.labels && } + {/* priority */} + + {/* due date */} + + {/* clear all filters */} +
+
Clear all filters
+
+
+ ); +}); + +export default IssueFilter; diff --git a/apps/space/components/issues/filters-render/label/filter-label-block.tsx b/apps/space/components/issues/filters-render/label/filter-label-block.tsx new file mode 100644 index 000000000..0606bfc95 --- /dev/null +++ b/apps/space/components/issues/filters-render/label/filter-label-block.tsx @@ -0,0 +1,34 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// mobx hook +import { useMobxStore } from "lib/mobx/store-provider"; +// interfaces +import { IIssueLabel } from "store/types/issue"; +// constants +import { issueGroupFilter } from "constants/data"; + +export const RenderIssueLabel = observer(({ label }: { label: IIssueLabel }) => { + const store = useMobxStore(); + + const removeLabelFromFilter = () => {}; + + return ( +
+
+
+
+
{label?.name}
+
+ close +
+
+ ); +}); diff --git a/apps/space/components/issues/filters-render/label/index.tsx b/apps/space/components/issues/filters-render/label/index.tsx new file mode 100644 index 000000000..7d313153a --- /dev/null +++ b/apps/space/components/issues/filters-render/label/index.tsx @@ -0,0 +1,37 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// components +import { RenderIssueLabel } from "./filter-label-block"; +// interfaces +import { IIssueLabel } from "store/types/issue"; +// mobx hook +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +const IssueLabelFilter = observer(() => { + const store: RootStore = useMobxStore(); + + const clearLabelFilters = () => {}; + + return ( + <> +
+
Labels
+
+ {store?.issue?.labels && + store?.issue?.labels.map((_label: IIssueLabel, _index: number) => )} +
+
+ close +
+
+ + ); +}); + +export default IssueLabelFilter; diff --git a/apps/space/components/issues/filters-render/priority/filter-priority-block.tsx b/apps/space/components/issues/filters-render/priority/filter-priority-block.tsx new file mode 100644 index 000000000..98173fd66 --- /dev/null +++ b/apps/space/components/issues/filters-render/priority/filter-priority-block.tsx @@ -0,0 +1,33 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// mobx hook +import { useMobxStore } from "lib/mobx/store-provider"; +// interfaces +import { IIssuePriorityFilters } from "store/types/issue"; + +export const RenderIssuePriority = observer(({ priority }: { priority: IIssuePriorityFilters }) => { + const store = useMobxStore(); + + const removePriorityFromFilter = () => {}; + + return ( +
+
+ {priority?.icon} +
+
{priority?.title}
+
+ close +
+
+ ); +}); diff --git a/apps/space/components/issues/filters-render/priority/index.tsx b/apps/space/components/issues/filters-render/priority/index.tsx new file mode 100644 index 000000000..2253a0be2 --- /dev/null +++ b/apps/space/components/issues/filters-render/priority/index.tsx @@ -0,0 +1,36 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// mobx hook +import { useMobxStore } from "lib/mobx/store-provider"; +// components +import { RenderIssuePriority } from "./filter-priority-block"; +// interfaces +import { IIssuePriorityFilters } from "store/types/issue"; +// constants +import { issuePriorityFilters } from "constants/data"; + +const IssuePriorityFilter = observer(() => { + const store = useMobxStore(); + + return ( + <> +
+
Priority
+
+ {issuePriorityFilters.map((_priority: IIssuePriorityFilters, _index: number) => ( + + ))} +
+
+ close +
+
{" "} + + ); +}); + +export default IssuePriorityFilter; diff --git a/apps/space/components/issues/filters-render/state/filter-state-block.tsx b/apps/space/components/issues/filters-render/state/filter-state-block.tsx new file mode 100644 index 000000000..95a4f4c70 --- /dev/null +++ b/apps/space/components/issues/filters-render/state/filter-state-block.tsx @@ -0,0 +1,38 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// mobx hook +import { useMobxStore } from "lib/mobx/store-provider"; +// interfaces +import { IIssueState } from "store/types/issue"; +// constants +import { issueGroupFilter } from "constants/data"; + +export const RenderIssueState = observer(({ state }: { state: IIssueState }) => { + const store = useMobxStore(); + + const stateGroup = issueGroupFilter(state.group); + + const removeStateFromFilter = () => {}; + + if (stateGroup === null) return <>; + return ( +
+
+ +
+
{state?.name}
+
+ close +
+
+ ); +}); diff --git a/apps/space/components/issues/filters-render/state/index.tsx b/apps/space/components/issues/filters-render/state/index.tsx new file mode 100644 index 000000000..fc73af381 --- /dev/null +++ b/apps/space/components/issues/filters-render/state/index.tsx @@ -0,0 +1,37 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// components +import { RenderIssueState } from "./filter-state-block"; +// interfaces +import { IIssueState } from "store/types/issue"; +// mobx hook +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +const IssueStateFilter = observer(() => { + const store: RootStore = useMobxStore(); + + const clearStateFilters = () => {}; + + return ( + <> +
+
State
+
+ {store?.issue?.states && + store?.issue?.states.map((_state: IIssueState, _index: number) => )} +
+
+ close +
+
+ + ); +}); + +export default IssueStateFilter; diff --git a/apps/space/components/issues/navbar/index.tsx b/apps/space/components/issues/navbar/index.tsx new file mode 100644 index 000000000..0207aaee2 --- /dev/null +++ b/apps/space/components/issues/navbar/index.tsx @@ -0,0 +1,54 @@ +"use client"; + +// components +import { NavbarSearch } from "./search"; +import { NavbarIssueBoardView } from "./issue-board-view"; +import { NavbarIssueFilter } from "./issue-filter"; +import { NavbarIssueView } from "./issue-view"; +import { NavbarTheme } from "./theme"; +// mobx react lite +import { observer } from "mobx-react-lite"; +// mobx +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +const IssueNavbar = observer(() => { + const store: RootStore = useMobxStore(); + + return ( +
+ {/* project detail */} +
+
+ {store?.project?.project && store?.project?.project?.icon ? store?.project?.project?.icon : "😊"} +
+
+ {store?.project?.project?.name || `...`} +
+
+ + {/* issue search bar */} +
+ +
+ + {/* issue views */} +
+ +
+ + {/* issue filters */} + {/*
+ + +
*/} + + {/* theming */} + {/*
+ +
*/} +
+ ); +}); + +export default IssueNavbar; diff --git a/apps/space/components/issues/navbar/issue-board-view.tsx b/apps/space/components/issues/navbar/issue-board-view.tsx new file mode 100644 index 000000000..57c8b27c1 --- /dev/null +++ b/apps/space/components/issues/navbar/issue-board-view.tsx @@ -0,0 +1,54 @@ +"use client"; + +// next imports +import { useRouter, useParams } from "next/navigation"; +// mobx react lite +import { observer } from "mobx-react-lite"; +// constants +import { issueViews } from "constants/data"; +// interfaces +import { TIssueBoardKeys } from "store/types"; +// mobx +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +export const NavbarIssueBoardView = observer(() => { + const store: RootStore = useMobxStore(); + + const router = useRouter(); + const routerParams = useParams(); + + const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string }; + + const handleCurrentBoardView = (boardView: TIssueBoardKeys) => { + store?.issue?.setCurrentIssueBoardView(boardView); + router.replace(`/${workspace_slug}/${project_slug}?board=${boardView}`); + }; + + return ( + <> + {store?.project?.workspaceProjectSettings && + issueViews && + issueViews.length > 0 && + issueViews.map( + (_view) => + store?.project?.workspaceProjectSettings?.views[_view?.key] && ( +
handleCurrentBoardView(_view?.key)} + title={_view?.title} + > + + {_view?.icon} + +
+ ) + )} + + ); +}); diff --git a/apps/space/components/issues/navbar/issue-filter.tsx b/apps/space/components/issues/navbar/issue-filter.tsx new file mode 100644 index 000000000..10255882d --- /dev/null +++ b/apps/space/components/issues/navbar/issue-filter.tsx @@ -0,0 +1,13 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// mobx +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +export const NavbarIssueFilter = observer(() => { + const store: RootStore = useMobxStore(); + + return
Filter
; +}); diff --git a/apps/space/components/issues/navbar/issue-view.tsx b/apps/space/components/issues/navbar/issue-view.tsx new file mode 100644 index 000000000..0a8f5c860 --- /dev/null +++ b/apps/space/components/issues/navbar/issue-view.tsx @@ -0,0 +1,13 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// mobx +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +export const NavbarIssueView = observer(() => { + const store: RootStore = useMobxStore(); + + return
View
; +}); diff --git a/apps/space/components/issues/navbar/search.tsx b/apps/space/components/issues/navbar/search.tsx new file mode 100644 index 000000000..d1cafea6a --- /dev/null +++ b/apps/space/components/issues/navbar/search.tsx @@ -0,0 +1,13 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// mobx +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +export const NavbarSearch = observer(() => { + const store: RootStore = useMobxStore(); + + return
; +}); diff --git a/apps/space/components/issues/navbar/theme.tsx b/apps/space/components/issues/navbar/theme.tsx new file mode 100644 index 000000000..c122f8478 --- /dev/null +++ b/apps/space/components/issues/navbar/theme.tsx @@ -0,0 +1,28 @@ +"use client"; + +// mobx react lite +import { observer } from "mobx-react-lite"; +// mobx +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +export const NavbarTheme = observer(() => { + const store: RootStore = useMobxStore(); + + const handleTheme = () => { + store?.theme?.setTheme(store?.theme?.theme === "light" ? "dark" : "light"); + }; + + return ( +
+ {store?.theme?.theme === "light" ? ( + dark_mode + ) : ( + light_mode + )} +
+ ); +}); diff --git a/apps/space/constants/data.ts b/apps/space/constants/data.ts new file mode 100644 index 000000000..81ccae116 --- /dev/null +++ b/apps/space/constants/data.ts @@ -0,0 +1,153 @@ +// interfaces +import { + IIssueBoardViews, + // priority + TIssuePriorityKey, + // state groups + TIssueGroupKey, + IIssuePriorityFilters, + IIssueGroup, +} from "store/types/issue"; +// icons +import { + BacklogStateIcon, + UnstartedStateIcon, + StartedStateIcon, + CompletedStateIcon, + CancelledStateIcon, +} from "components/icons"; + +// all issue views +export const issueViews: IIssueBoardViews[] = [ + { + key: "list", + title: "List View", + icon: "format_list_bulleted", + className: "", + }, + { + key: "kanban", + title: "Board View", + icon: "grid_view", + className: "", + }, + // { + // key: "calendar", + // title: "Calendar View", + // icon: "calendar_month", + // className: "", + // }, + // { + // key: "spreadsheet", + // title: "Spreadsheet View", + // icon: "table_chart", + // className: "", + // }, + // { + // key: "gantt", + // title: "Gantt Chart View", + // icon: "waterfall_chart", + // className: "rotate-90", + // }, +]; + +// issue priority filters +export const issuePriorityFilters: IIssuePriorityFilters[] = [ + { + key: "urgent", + title: "Urgent", + className: "border border-red-500/50 bg-red-500/20 text-red-500", + icon: "error", + }, + { + key: "high", + title: "High", + className: "border border-orange-500/50 bg-orange-500/20 text-orange-500", + icon: "signal_cellular_alt", + }, + { + key: "medium", + title: "Medium", + className: "border border-yellow-500/50 bg-yellow-500/20 text-yellow-500", + icon: "signal_cellular_alt_2_bar", + }, + { + key: "low", + title: "Low", + className: "border border-green-500/50 bg-green-500/20 text-green-500", + icon: "signal_cellular_alt_1_bar", + }, + { + key: "none", + title: "None", + className: "border border-gray-500/50 bg-gray-500/20 text-gray-500", + icon: "block", + }, +]; + +export const issuePriorityFilter = (priorityKey: TIssuePriorityKey): IIssuePriorityFilters | null => { + const currentIssuePriority: IIssuePriorityFilters | undefined | null = + issuePriorityFilters && issuePriorityFilters.length > 0 + ? issuePriorityFilters.find((_priority) => _priority.key === priorityKey) + : null; + + if (currentIssuePriority === undefined || currentIssuePriority === null) return null; + return { ...currentIssuePriority }; +}; + +// issue group filters +export const issueGroupColors: { + [key: string]: string; +} = { + backlog: "#d9d9d9", + unstarted: "#3f76ff", + started: "#f59e0b", + completed: "#16a34a", + cancelled: "#dc2626", +}; + +export const issueGroups: IIssueGroup[] = [ + { + key: "backlog", + title: "Backlog", + color: "#d9d9d9", + className: `border-[#d9d9d9]/50 text-[#d9d9d9] bg-[#d9d9d9]/10`, + icon: BacklogStateIcon, + }, + { + key: "unstarted", + title: "Unstarted", + color: "#3f76ff", + className: `border-[#3f76ff]/50 text-[#3f76ff] bg-[#3f76ff]/10`, + icon: UnstartedStateIcon, + }, + { + key: "started", + title: "Started", + color: "#f59e0b", + className: `border-[#f59e0b]/50 text-[#f59e0b] bg-[#f59e0b]/10`, + icon: StartedStateIcon, + }, + { + key: "completed", + title: "Completed", + color: "#16a34a", + className: `border-[#16a34a]/50 text-[#16a34a] bg-[#16a34a]/10`, + icon: CompletedStateIcon, + }, + { + key: "cancelled", + title: "Cancelled", + color: "#dc2626", + className: `border-[#dc2626]/50 text-[#dc2626] bg-[#dc2626]/10`, + icon: CancelledStateIcon, + }, +]; + +export const issueGroupFilter = (issueKey: TIssueGroupKey): IIssueGroup | null => { + const currentIssueStateGroup: IIssueGroup | undefined | null = + issueGroups && issueGroups.length > 0 ? issueGroups.find((group) => group.key === issueKey) : null; + + if (currentIssueStateGroup === undefined || currentIssueStateGroup === null) return null; + return { ...currentIssueStateGroup }; +}; diff --git a/apps/space/constants/helpers.ts b/apps/space/constants/helpers.ts new file mode 100644 index 000000000..fd4dba217 --- /dev/null +++ b/apps/space/constants/helpers.ts @@ -0,0 +1,13 @@ +export const renderDateFormat = (date: string | Date | null) => { + if (!date) return "N/A"; + + var d = new Date(date), + month = "" + (d.getMonth() + 1), + day = "" + d.getDate(), + year = d.getFullYear(); + + if (month.length < 2) month = "0" + month; + if (day.length < 2) day = "0" + day; + + return [year, month, day].join("-"); +}; diff --git a/apps/space/lib/mobx-store/root.ts b/apps/space/lib/index.ts similarity index 100% rename from apps/space/lib/mobx-store/root.ts rename to apps/space/lib/index.ts diff --git a/apps/space/lib/mobx/store-init.tsx b/apps/space/lib/mobx/store-init.tsx new file mode 100644 index 000000000..2ba2f9024 --- /dev/null +++ b/apps/space/lib/mobx/store-init.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useEffect } from "react"; +// next imports +import { useSearchParams } from "next/navigation"; +// interface +import { TIssueBoardKeys } from "store/types"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +import { RootStore } from "store/root"; + +const MobxStoreInit = () => { + const store: RootStore = useMobxStore(); + + // search params + const routerSearchparams = useSearchParams(); + + const board = routerSearchparams.get("board") as TIssueBoardKeys; + + useEffect(() => { + // theme + const _theme = localStorage && localStorage.getItem("app_theme") ? localStorage.getItem("app_theme") : "light"; + if (_theme && store?.theme?.theme != _theme) store.theme.setTheme(_theme); + else localStorage.setItem("app_theme", _theme && _theme != "light" ? "dark" : "light"); + }, [store?.theme]); + + // updating default board view when we are in the issues page + useEffect(() => { + if (board && board != store?.issue?.currentIssueBoardView) store.issue.setCurrentIssueBoardView(board); + }, [board, store?.issue]); + + return <>; +}; + +export default MobxStoreInit; diff --git a/apps/space/lib/mobx/store-provider.tsx b/apps/space/lib/mobx/store-provider.tsx new file mode 100644 index 000000000..c6fde14ae --- /dev/null +++ b/apps/space/lib/mobx/store-provider.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { createContext, useContext } from "react"; +// mobx store +import { RootStore } from "store/root"; + +let rootStore: RootStore = new RootStore(); + +export const MobxStoreContext = createContext(rootStore); + +const initializeStore = () => { + const _rootStore: RootStore = rootStore ?? new RootStore(); + if (typeof window === "undefined") return _rootStore; + if (!rootStore) rootStore = _rootStore; + return _rootStore; +}; + +export const MobxStoreProvider = ({ children }: any) => { + const store: RootStore = initializeStore(); + return {children}; +}; + +// hook +export const useMobxStore = () => { + const context = useContext(MobxStoreContext); + if (context === undefined) throw new Error("useMobxStore must be used within MobxStoreProvider"); + return context; +}; diff --git a/apps/space/package.json b/apps/space/package.json index 7ace168aa..4af31d312 100644 --- a/apps/space/package.json +++ b/apps/space/package.json @@ -18,6 +18,8 @@ "eslint": "8.34.0", "eslint-config-next": "13.2.1", "js-cookie": "^3.0.1", + "mobx": "^6.10.0", + "mobx-react-lite": "^4.0.3", "next": "^13.4.13", "nprogress": "^0.2.0", "react": "^18.2.0", diff --git a/apps/space/public/plane-logo.webp b/apps/space/public/plane-logo.webp new file mode 100644 index 000000000..52e7c98da Binary files /dev/null and b/apps/space/public/plane-logo.webp differ diff --git a/apps/space/services/api.service.ts b/apps/space/services/api.service.ts new file mode 100644 index 000000000..900d5d15f --- /dev/null +++ b/apps/space/services/api.service.ts @@ -0,0 +1,100 @@ +// axios +import axios from "axios"; +// js cookie +import Cookies from "js-cookie"; + +const base_url: string | null = "https://boarding.plane.so"; + +abstract class APIService { + protected baseURL: string; + protected headers: any = {}; + + constructor(baseURL: string) { + this.baseURL = base_url ? base_url : baseURL; + } + + setRefreshToken(token: string) { + Cookies.set("refreshToken", token); + } + + getRefreshToken() { + return Cookies.get("refreshToken"); + } + + purgeRefreshToken() { + Cookies.remove("refreshToken", { path: "/" }); + } + + setAccessToken(token: string) { + Cookies.set("accessToken", token); + } + + getAccessToken() { + return Cookies.get("accessToken"); + } + + purgeAccessToken() { + Cookies.remove("accessToken", { path: "/" }); + } + + getHeaders() { + return { + Authorization: `Bearer ${this.getAccessToken()}`, + }; + } + + get(url: string, config = {}): Promise { + return axios({ + method: "get", + url: this.baseURL + url, + headers: this.getAccessToken() ? this.getHeaders() : {}, + ...config, + }); + } + + post(url: string, data = {}, config = {}): Promise { + return axios({ + method: "post", + url: this.baseURL + url, + data, + headers: this.getAccessToken() ? this.getHeaders() : {}, + ...config, + }); + } + + put(url: string, data = {}, config = {}): Promise { + return axios({ + method: "put", + url: this.baseURL + url, + data, + headers: this.getAccessToken() ? this.getHeaders() : {}, + ...config, + }); + } + + patch(url: string, data = {}, config = {}): Promise { + return axios({ + method: "patch", + url: this.baseURL + url, + data, + headers: this.getAccessToken() ? this.getHeaders() : {}, + ...config, + }); + } + + delete(url: string, data?: any, config = {}): Promise { + return axios({ + method: "delete", + url: this.baseURL + url, + data: data, + headers: this.getAccessToken() ? this.getHeaders() : {}, + ...config, + }); + } + + request(config = {}) { + return axios(config); + } +} + +export default APIService; diff --git a/apps/space/services/issue.service.ts b/apps/space/services/issue.service.ts new file mode 100644 index 000000000..4b40bdf5c --- /dev/null +++ b/apps/space/services/issue.service.ts @@ -0,0 +1,20 @@ +// services +import APIService from "services/api.service"; + +const { NEXT_PUBLIC_API_BASE_URL } = process.env; + +class IssueService extends APIService { + constructor() { + super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + } + + async getPublicIssues(workspace_slug: string, project_slug: string): Promise { + return this.get(`/api/public/workspaces/${workspace_slug}/project-boards/${project_slug}/issues/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } +} + +export default IssueService; diff --git a/apps/space/services/project.service.ts b/apps/space/services/project.service.ts new file mode 100644 index 000000000..4d973051f --- /dev/null +++ b/apps/space/services/project.service.ts @@ -0,0 +1,20 @@ +// services +import APIService from "services/api.service"; + +const { NEXT_PUBLIC_API_BASE_URL } = process.env; + +class ProjectService extends APIService { + constructor() { + super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + } + + async getProjectSettingsAsync(workspace_slug: string, project_slug: string): Promise { + return this.get(`/api/public/workspaces/${workspace_slug}/project-boards/${project_slug}/settings/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } +} + +export default ProjectService; diff --git a/apps/space/services/user.service.ts b/apps/space/services/user.service.ts new file mode 100644 index 000000000..d724374b6 --- /dev/null +++ b/apps/space/services/user.service.ts @@ -0,0 +1,20 @@ +// services +import APIService from "services/api.service"; + +const { NEXT_PUBLIC_API_BASE_URL } = process.env; + +class UserService extends APIService { + constructor() { + super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + } + + async currentUser(): Promise { + return this.get("/api/users/me/") + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } +} + +export default UserService; diff --git a/apps/space/store/issue.ts b/apps/space/store/issue.ts new file mode 100644 index 000000000..79ad4b910 --- /dev/null +++ b/apps/space/store/issue.ts @@ -0,0 +1,91 @@ +// mobx +import { observable, action, computed, makeObservable, runInAction } from "mobx"; +// service +import IssueService from "services/issue.service"; +// types +import { TIssueBoardKeys } from "store/types/issue"; +import { IIssueStore, IIssue, IIssueState, IIssueLabel } from "./types"; + +class IssueStore implements IIssueStore { + currentIssueBoardView: TIssueBoardKeys | null = null; + + loader: boolean = false; + error: any | null = null; + + states: IIssueState[] | null = null; + labels: IIssueLabel[] | null = null; + issues: IIssue[] | null = null; + + userSelectedStates: string[] = []; + userSelectedLabels: string[] = []; + // root store + rootStore; + // service + issueService; + + constructor(_rootStore: any) { + makeObservable(this, { + // observable + currentIssueBoardView: observable, + + loader: observable, + error: observable, + + states: observable.ref, + labels: observable.ref, + issues: observable.ref, + + userSelectedStates: observable, + userSelectedLabels: observable, + // action + setCurrentIssueBoardView: action, + getIssuesAsync: action, + // computed + }); + + this.rootStore = _rootStore; + this.issueService = new IssueService(); + } + + // computed + getCountOfIssuesByState(state_id: string): number { + return this.issues?.filter((issue) => issue.state == state_id).length || 0; + } + + getFilteredIssuesByState(state_id: string): IIssue[] | [] { + return this.issues?.filter((issue) => issue.state == state_id) || []; + } + + // action + setCurrentIssueBoardView = async (view: TIssueBoardKeys) => { + this.currentIssueBoardView = view; + }; + + getIssuesAsync = async (workspace_slug: string, project_slug: string) => { + try { + this.loader = true; + this.error = null; + + const response = await this.issueService.getPublicIssues(workspace_slug, project_slug); + + if (response) { + const _states: IIssueState[] = [...response?.states]; + const _labels: IIssueLabel[] = [...response?.labels]; + const _issues: IIssue[] = [...response?.issues]; + runInAction(() => { + this.states = _states; + this.labels = _labels; + this.issues = _issues; + this.loader = false; + }); + return response; + } + } catch (error) { + this.loader = false; + this.error = error; + return error; + } + }; +} + +export default IssueStore; diff --git a/apps/space/store/project.ts b/apps/space/store/project.ts new file mode 100644 index 000000000..e5ac58261 --- /dev/null +++ b/apps/space/store/project.ts @@ -0,0 +1,69 @@ +// mobx +import { observable, action, makeObservable, runInAction } from "mobx"; +// service +import ProjectService from "services/project.service"; +// types +import { IProjectStore, IWorkspace, IProject, IProjectSettings } from "./types"; + +class ProjectStore implements IProjectStore { + loader: boolean = false; + error: any | null = null; + + workspace: IWorkspace | null = null; + project: IProject | null = null; + workspaceProjectSettings: IProjectSettings | null = null; + // root store + rootStore; + // service + projectService; + + constructor(_rootStore: any | null = null) { + makeObservable(this, { + // observable + workspace: observable.ref, + project: observable.ref, + workspaceProjectSettings: observable.ref, + loader: observable, + error: observable.ref, + // action + getProjectSettingsAsync: action, + // computed + }); + + this.rootStore = _rootStore; + this.projectService = new ProjectService(); + } + + getProjectSettingsAsync = async (workspace_slug: string, project_slug: string) => { + try { + this.loader = true; + this.error = null; + + const response = await this.projectService.getProjectSettingsAsync(workspace_slug, project_slug); + + if (response) { + const _project: IProject = { ...response?.project_details }; + const _workspace: IWorkspace = { ...response?.workspace_detail }; + const _workspaceProjectSettings: IProjectSettings = { + comments: response?.comments, + reactions: response?.reactions, + votes: response?.votes, + views: { ...response?.views }, + }; + runInAction(() => { + this.project = _project; + this.workspace = _workspace; + this.workspaceProjectSettings = _workspaceProjectSettings; + this.loader = false; + }); + } + return response; + } catch (error) { + this.loader = false; + this.error = error; + return error; + } + }; +} + +export default ProjectStore; diff --git a/apps/space/store/root.ts b/apps/space/store/root.ts index a10356821..dd6d620c0 100644 --- a/apps/space/store/root.ts +++ b/apps/space/store/root.ts @@ -1 +1,25 @@ -export const init = {}; +// mobx lite +import { enableStaticRendering } from "mobx-react-lite"; +// store imports +import UserStore from "./user"; +import ThemeStore from "./theme"; +import IssueStore from "./issue"; +import ProjectStore from "./project"; +// types +import { IIssueStore, IProjectStore, IThemeStore, IUserStore } from "./types"; + +enableStaticRendering(typeof window === "undefined"); + +export class RootStore { + user: IUserStore; + theme: IThemeStore; + issue: IIssueStore; + project: IProjectStore; + + constructor() { + this.user = new UserStore(this); + this.theme = new ThemeStore(this); + this.issue = new IssueStore(this); + this.project = new ProjectStore(this); + } +} diff --git a/apps/space/store/theme.ts b/apps/space/store/theme.ts new file mode 100644 index 000000000..809d56b97 --- /dev/null +++ b/apps/space/store/theme.ts @@ -0,0 +1,33 @@ +// mobx +import { observable, action, computed, makeObservable, runInAction } from "mobx"; +// types +import { IThemeStore } from "./types"; + +class ThemeStore implements IThemeStore { + theme: "light" | "dark" = "light"; + // root store + rootStore; + + constructor(_rootStore: any | null = null) { + makeObservable(this, { + // observable + theme: observable, + // action + setTheme: action, + // computed + }); + + this.rootStore = _rootStore; + } + + setTheme = async (_theme: "light" | "dark" | string) => { + try { + localStorage.setItem("app_theme", _theme); + this.theme = _theme === "light" ? "light" : "dark"; + } catch (error) { + console.error("setting user theme error", error); + } + }; +} + +export default ThemeStore; diff --git a/apps/space/store/types/index.ts b/apps/space/store/types/index.ts new file mode 100644 index 000000000..5a0a51eda --- /dev/null +++ b/apps/space/store/types/index.ts @@ -0,0 +1,4 @@ +export * from "./user"; +export * from "./theme"; +export * from "./project"; +export * from "./issue"; diff --git a/apps/space/store/types/issue.ts b/apps/space/store/types/issue.ts new file mode 100644 index 000000000..5feeba7bd --- /dev/null +++ b/apps/space/store/types/issue.ts @@ -0,0 +1,72 @@ +export type TIssueBoardKeys = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt"; + +export interface IIssueBoardViews { + key: TIssueBoardKeys; + title: string; + icon: string; + className: string; +} + +export type TIssuePriorityKey = "urgent" | "high" | "medium" | "low" | "none"; +export type TIssuePriorityTitle = "Urgent" | "High" | "Medium" | "Low" | "None"; +export interface IIssuePriorityFilters { + key: TIssuePriorityKey; + title: TIssuePriorityTitle; + className: string; + icon: string; +} + +export type TIssueGroupKey = "backlog" | "unstarted" | "started" | "completed" | "cancelled"; +export type TIssueGroupTitle = "Backlog" | "Unstarted" | "Started" | "Completed" | "Cancelled"; + +export interface IIssueGroup { + key: TIssueGroupKey; + title: TIssueGroupTitle; + color: string; + className: string; + icon: React.FC; +} + +export interface IIssue { + id: string; + sequence_id: number; + name: string; + description_html: string; + priority: TIssuePriorityKey | null; + state: string; + state_detail: any; + label_details: any; + target_date: any; +} + +export interface IIssueState { + id: string; + name: string; + group: TIssueGroupKey; + color: string; +} + +export interface IIssueLabel { + id: string; + name: string; + color: string; +} + +export interface IIssueStore { + currentIssueBoardView: TIssueBoardKeys | null; + loader: boolean; + error: any | null; + + states: IIssueState[] | null; + labels: IIssueLabel[] | null; + issues: IIssue[] | null; + + userSelectedStates: string[]; + userSelectedLabels: string[]; + + getCountOfIssuesByState: (state: string) => number; + getFilteredIssuesByState: (state: string) => IIssue[]; + + setCurrentIssueBoardView: (view: TIssueBoardKeys) => void; + getIssuesAsync: (workspace_slug: string, project_slug: string) => Promise; +} diff --git a/apps/space/store/types/project.ts b/apps/space/store/types/project.ts new file mode 100644 index 000000000..a55f30be0 --- /dev/null +++ b/apps/space/store/types/project.ts @@ -0,0 +1,39 @@ +export interface IWorkspace { + id: string; + name: string; + slug: string; +} + +export interface IProject { + id: string; + identifier: string; + name: string; + icon: string; + cover_image: string | null; + icon_prop: string | null; + emoji: string | null; +} + +export interface IProjectSettings { + comments: boolean; + reactions: boolean; + votes: boolean; + views: { + list: boolean; + gantt: boolean; + kanban: boolean; + calendar: boolean; + spreadsheet: boolean; + }; +} + +export interface IProjectStore { + loader: boolean; + error: any | null; + + workspace: IWorkspace | null; + project: IProject | null; + workspaceProjectSettings: IProjectSettings | null; + + getProjectSettingsAsync: (workspace_slug: string, project_slug: string) => Promise; +} diff --git a/apps/space/store/types/theme.ts b/apps/space/store/types/theme.ts new file mode 100644 index 000000000..ca306be51 --- /dev/null +++ b/apps/space/store/types/theme.ts @@ -0,0 +1,4 @@ +export interface IThemeStore { + theme: string; + setTheme: (theme: "light" | "dark" | string) => void; +} diff --git a/apps/space/store/types/user.ts b/apps/space/store/types/user.ts new file mode 100644 index 000000000..0293c5381 --- /dev/null +++ b/apps/space/store/types/user.ts @@ -0,0 +1,4 @@ +export interface IUserStore { + currentUser: any | null; + getUserAsync: () => void; +} diff --git a/apps/space/store/user.ts b/apps/space/store/user.ts new file mode 100644 index 000000000..2f4782236 --- /dev/null +++ b/apps/space/store/user.ts @@ -0,0 +1,43 @@ +// mobx +import { observable, action, computed, makeObservable, runInAction } from "mobx"; +// service +import UserService from "services/user.service"; +// types +import { IUserStore } from "./types"; + +class UserStore implements IUserStore { + currentUser: any | null = null; + // root store + rootStore; + // service + userService; + + constructor(_rootStore: any) { + makeObservable(this, { + // observable + currentUser: observable, + // actions + // computed + }); + this.rootStore = _rootStore; + this.userService = new UserService(); + } + + getUserAsync = async () => { + try { + const response = this.userService.currentUser(); + if (response) { + runInAction(() => { + this.currentUser = response; + }); + } + } catch (error) { + console.error("error", error); + runInAction(() => { + // render error actions + }); + } + }; +} + +export default UserStore; diff --git a/apps/space/tailwind.config.js b/apps/space/tailwind.config.js index 145c65b5a..55aaa9a31 100644 --- a/apps/space/tailwind.config.js +++ b/apps/space/tailwind.config.js @@ -6,6 +6,7 @@ module.exports = { "./pages/**/*.{js,ts,jsx,tsx}", "./layouts/**/*.tsx", "./components/**/*.{js,ts,jsx,tsx}", + "./constants/**/*.{js,ts,jsx,tsx}", ], theme: { extend: { diff --git a/yarn.lock b/yarn.lock index 9f1999d04..578f10026 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2993,26 +2993,26 @@ resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.3.3.tgz#16ab6c727d8c2020a5b6e4a176a243ecd88d8d69" integrity sha512-0xd7qez0AQ+MbHatZTlI1gu5vkG8r7MYRUJAHPAHJBmGLs16zpkrpAVLvjQKQOqaXPDUBwOiJzNc00znHSCVBw== -"@sentry-internal/tracing@7.61.1": - version "7.61.1" - resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.61.1.tgz#8055b7dfbf89b7089a591b27e05484d5f6773948" - integrity sha512-E8J6ZMXHGdWdmgKBK/ounuUppDK65c4Hphin6iVckDGMEATn0auYAKngeyRUMLof1167DssD8wxcIA4aBvmScA== +"@sentry-internal/tracing@7.62.0": + version "7.62.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.62.0.tgz#f14400f20a32844f2895a8a333080d52fa32cd1d" + integrity sha512-LHT8i2c93JhQ1uBU1cqb5AIhmHPWlyovE4ZQjqEizk6Fk7jXc9L8kKhaIWELVPn8Xg6YtfGWhRBZk3ssj4JpfQ== dependencies: - "@sentry/core" "7.61.1" - "@sentry/types" "7.61.1" - "@sentry/utils" "7.61.1" + "@sentry/core" "7.62.0" + "@sentry/types" "7.62.0" + "@sentry/utils" "7.62.0" tslib "^2.4.1 || ^1.9.3" -"@sentry/browser@7.61.1": - version "7.61.1" - resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.61.1.tgz#ce5005ea76d4c2e91c09a43b218c25cc5e9c1340" - integrity sha512-v6Wv0O/PF+sqji+WWpJmxAlQafsiKmsXQLzKAIntVjl3HbYO5oVS3ubCyqfxSlLxIhM5JuHcEOLn6Zi3DPtpcw== +"@sentry/browser@7.62.0": + version "7.62.0" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.62.0.tgz#0b00a0ed8e4cd4873f7ec413b094ec6b170bb085" + integrity sha512-e52EPiRtPTZv+9iFIZT3n8qNozc8ymqT0ra7QwkwbVuF9fWSCOc1gzkTa9VKd/xwcGzOfglozl2O+Zz4GtoGUg== dependencies: - "@sentry-internal/tracing" "7.61.1" - "@sentry/core" "7.61.1" - "@sentry/replay" "7.61.1" - "@sentry/types" "7.61.1" - "@sentry/utils" "7.61.1" + "@sentry-internal/tracing" "7.62.0" + "@sentry/core" "7.62.0" + "@sentry/replay" "7.62.0" + "@sentry/types" "7.62.0" + "@sentry/utils" "7.62.0" tslib "^2.4.1 || ^1.9.3" "@sentry/cli@^1.74.6": @@ -3027,88 +3027,88 @@ proxy-from-env "^1.1.0" which "^2.0.2" -"@sentry/core@7.61.1": - version "7.61.1" - resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.61.1.tgz#8043c7cecf5ca0601f6c61979fb2880ceac37287" - integrity sha512-WTRt0J33KhUbYuDQZ5G58kdsNeQ5JYrpi6o+Qz+1xTv60DQq/tBGRJ7d86SkmdnGIiTs6W1hsxAtyiLS0y9d2A== +"@sentry/core@7.62.0": + version "7.62.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.62.0.tgz#3d9571741b052b1f2fa8fb8ae0088de8e79b4f4e" + integrity sha512-l6n+c3mSlWa+FhT/KBrAU1BtbaLYCljf5MuGlH6NKRpnBcrZCbzk8ZuFcSND+gr2SqxycQkhEWX1zxVHPDdZxw== dependencies: - "@sentry/types" "7.61.1" - "@sentry/utils" "7.61.1" + "@sentry/types" "7.62.0" + "@sentry/utils" "7.62.0" tslib "^2.4.1 || ^1.9.3" -"@sentry/integrations@7.61.1": - version "7.61.1" - resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.61.1.tgz#ca9bf2fc59c852f5e73543bb7e69b181a4ef2d45" - integrity sha512-mdmWzUQmW1viOiW0/Gi6AQ5LXukqhuefjzLdn5o6HMxiAgskIpNX+0+BOQ/6162/o7mHWSTNEHqEzMNTK2ppLw== +"@sentry/integrations@7.62.0": + version "7.62.0" + resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.62.0.tgz#fad35d8de97890b35269d132636218ae157dab22" + integrity sha512-BNlW4xczhbL+zmmc8kFZunjKBrVYZsAltQ/gMuaHw5iiEr+chVMgQDQ2A9EVB7WEtuTJQ0XmeqofH2nAk2qYHg== dependencies: - "@sentry/types" "7.61.1" - "@sentry/utils" "7.61.1" + "@sentry/types" "7.62.0" + "@sentry/utils" "7.62.0" localforage "^1.8.1" tslib "^2.4.1 || ^1.9.3" "@sentry/nextjs@^7.36.0": - version "7.61.1" - resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-7.61.1.tgz#556bd48740dd67694ee54aaed042a22c255290ed" - integrity sha512-ssq0AX+QaDzLSeA45lQLt3OVkzUNiNsI5loMU9gq+Bsts3KOHnykturFvdrb5T3WuIucE6PsswNjZIWqP+lrMg== + version "7.62.0" + resolved "https://registry.yarnpkg.com/@sentry/nextjs/-/nextjs-7.62.0.tgz#6a5362dc03c768e8ef855ea7c26f94dddc40d7eb" + integrity sha512-Hg5D8dAgGkn+ZoTh2SSOx35hcVJUf9QO4D2FKFmPwFpnrpP/thcusE7m2k6jsUlK6jBvZhtC0rcZk26K3WsioA== dependencies: "@rollup/plugin-commonjs" "24.0.0" - "@sentry/core" "7.61.1" - "@sentry/integrations" "7.61.1" - "@sentry/node" "7.61.1" - "@sentry/react" "7.61.1" - "@sentry/types" "7.61.1" - "@sentry/utils" "7.61.1" + "@sentry/core" "7.62.0" + "@sentry/integrations" "7.62.0" + "@sentry/node" "7.62.0" + "@sentry/react" "7.62.0" + "@sentry/types" "7.62.0" + "@sentry/utils" "7.62.0" "@sentry/webpack-plugin" "1.20.0" chalk "3.0.0" rollup "2.78.0" stacktrace-parser "^0.1.10" tslib "^2.4.1 || ^1.9.3" -"@sentry/node@7.61.1": - version "7.61.1" - resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.61.1.tgz#bc49d321d0a511936f8bdd0bbd3ddc5e01b8d98c" - integrity sha512-+crVAeymXdWZcDuwU9xySf4sVv2fHOFlr13XqeXl73q4zqKJM1IX4VUO9On3+jTyGfB5SCAuBBYpzA3ehBfeYw== +"@sentry/node@7.62.0": + version "7.62.0" + resolved "https://registry.yarnpkg.com/@sentry/node/-/node-7.62.0.tgz#8ccac64974748705103fccd3cf40f76003bad94a" + integrity sha512-2z1JmYV97eJ8zwshJA15hppjRdUeMhbaL8LSsbdtx7vTMmjuaIGfPR4EnI4Fhuw+J1Nnf5sE/CRKpZCCa74vXw== dependencies: - "@sentry-internal/tracing" "7.61.1" - "@sentry/core" "7.61.1" - "@sentry/types" "7.61.1" - "@sentry/utils" "7.61.1" + "@sentry-internal/tracing" "7.62.0" + "@sentry/core" "7.62.0" + "@sentry/types" "7.62.0" + "@sentry/utils" "7.62.0" cookie "^0.4.1" https-proxy-agent "^5.0.0" lru_map "^0.3.3" tslib "^2.4.1 || ^1.9.3" -"@sentry/react@7.61.1": - version "7.61.1" - resolved "https://registry.yarnpkg.com/@sentry/react/-/react-7.61.1.tgz#88a62fe9a847ffb0feeff935c49737abd7904007" - integrity sha512-n8xNT05gdERpETvq3GJZ2lP6HZYLRQQoUDc13egDzKf840MzCjle0LiLmsVhRv8AL1GnWaIPwnvTGvS4BuNlvw== +"@sentry/react@7.62.0": + version "7.62.0" + resolved "https://registry.yarnpkg.com/@sentry/react/-/react-7.62.0.tgz#8fa7246ba61f57c007893d76dcd5784b4e12d34e" + integrity sha512-jCQEs6lYGQdqj6XXWdR+i5IzJMgrSzTFI/TSMSeTdAeldmppg7uuRuJlBJGaWsxoiwed539Vn3kitRswn1ugeA== dependencies: - "@sentry/browser" "7.61.1" - "@sentry/types" "7.61.1" - "@sentry/utils" "7.61.1" + "@sentry/browser" "7.62.0" + "@sentry/types" "7.62.0" + "@sentry/utils" "7.62.0" hoist-non-react-statics "^3.3.2" tslib "^2.4.1 || ^1.9.3" -"@sentry/replay@7.61.1": - version "7.61.1" - resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.61.1.tgz#20cdb5f31b5ce25a7afe11bcaaf67b1f875d2833" - integrity sha512-Nsnnzx8c+DRjnfQ0Md11KGdY21XOPa50T2B3eBEyFAhibvYEc/68PuyVWkMBQ7w9zo/JV+q6HpIXKD0THUtqZA== +"@sentry/replay@7.62.0": + version "7.62.0" + resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.62.0.tgz#9131c24ae2e797ae47983834ba88b3b5c7f6e566" + integrity sha512-mSbqtV6waQAvWTG07uR211jft63HduRXdHq+1xuaKulDcZ9chOkYqOCMpL0HjRIANEiZRTDDKlIo4s+3jkY5Ug== dependencies: - "@sentry/core" "7.61.1" - "@sentry/types" "7.61.1" - "@sentry/utils" "7.61.1" + "@sentry/core" "7.62.0" + "@sentry/types" "7.62.0" + "@sentry/utils" "7.62.0" -"@sentry/types@7.61.1": - version "7.61.1" - resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.61.1.tgz#225912689459c92e62f0b6e3ff145f6dbf72ff0e" - integrity sha512-CpPKL+OfwYOduRX9AT3p+Ie1fftgcCPd5WofTVVq7xeWRuerOOf2iJd0v+8yHQ25omgres1YOttDkCcvQRn4Jw== +"@sentry/types@7.62.0": + version "7.62.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.62.0.tgz#f15729f656459ffa3a5998fafe9d17ee7fb1c9ff" + integrity sha512-oPy/fIT3o2VQWLTq01R2W/jt13APYMqZCVa0IT3lF9lgxzgfTbeZl3nX2FgCcc8ntDZC0dVw03dL+wLvjPqQpQ== -"@sentry/utils@7.61.1": - version "7.61.1" - resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.61.1.tgz#1545db778b7309d122a7f04eb0e803173c80c581" - integrity sha512-pUPXoiuYrTEPcBHjRizFB6eZEGm/6cTBwdWSHUjkGKvt19zuZ1ixFJQV6LrIL/AMeiQbmfQ+kTd/8SR7E9rcTQ== +"@sentry/utils@7.62.0": + version "7.62.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.62.0.tgz#915501c6056d704a9625239a1f584a7b2e4492ea" + integrity sha512-12w+Lpvn2iaocgjf6AxhtBz7XG8iFE5aMyt9BTuQp1/7sOjtEVNHlDlGrHbtPqxNCmL2SEcmNHka1panLqWHDw== dependencies: - "@sentry/types" "7.61.1" + "@sentry/types" "7.62.0" tslib "^2.4.1 || ^1.9.3" "@sentry/webpack-plugin@1.20.0": @@ -3617,6 +3617,14 @@ "@typescript-eslint/types" "5.62.0" eslint-visitor-keys "^3.3.0" +"@typescript-eslint/visitor-keys@5.62.0": + version "5.62.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e" + integrity sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw== + dependencies: + "@typescript-eslint/types" "5.62.0" + eslint-visitor-keys "^3.3.0" + a11y-status@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/a11y-status/-/a11y-status-2.0.1.tgz#a7883105910b9e3cd09ea90e5acf8404dc01b47e" @@ -3641,6 +3649,11 @@ acorn@^8.8.2, acorn@^8.9.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5" integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== +acorn@^8.9.0: + version "8.10.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5" + integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== + agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -5180,6 +5193,56 @@ eslint@8.34.0: strip-json-comments "^3.1.0" text-table "^0.2.0" +eslint-visitor-keys@^3.4.1: + version "3.4.2" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.2.tgz#8c2095440eca8c933bedcadf16fefa44dbe9ba5f" + integrity sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw== + +eslint@8.34.0: + version "8.34.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.34.0.tgz#fe0ab0ef478104c1f9ebc5537e303d25a8fb22d6" + integrity sha512-1Z8iFsucw+7kSqXNZVslXS8Ioa4u2KM7GPwuKtkTFAqZ/cHMcEaR+1+Br0wLlot49cNxIiZk5wp8EAbPcYZxTg== + dependencies: + "@eslint/eslintrc" "^1.4.1" + "@humanwhocodes/config-array" "^0.11.8" + "@humanwhocodes/module-importer" "^1.0.1" + "@nodelib/fs.walk" "^1.2.8" + ajv "^6.10.0" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.1.1" + eslint-utils "^3.0.0" + eslint-visitor-keys "^3.3.0" + espree "^9.4.0" + esquery "^1.4.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + glob-parent "^6.0.2" + globals "^13.19.0" + grapheme-splitter "^1.0.4" + ignore "^5.2.0" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + is-path-inside "^3.0.3" + js-sdsl "^4.1.4" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.1" + regexpp "^3.2.0" + strip-ansi "^6.0.1" + strip-json-comments "^3.1.0" + text-table "^0.2.0" + eslint@^7.23.0, eslint@^7.32.0: version "7.32.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" @@ -5397,6 +5460,17 @@ fast-glob@^3.2.12, fast-glob@^3.2.9, fast-glob@^3.3.0: merge2 "^1.3.0" micromatch "^4.0.4" +fast-glob@^3.3.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4" + integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" @@ -6247,6 +6321,13 @@ is-wsl@^2.2.0: dependencies: is-docker "^2.0.0" +is-wsl@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + isarray@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" @@ -6439,9 +6520,9 @@ levn@^0.4.1: type-check "~0.4.0" lib0@^0.2.42, lib0@^0.2.74: - version "0.2.79" - resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.79.tgz#b82ee41bfab31a4358bbc0c8ad0645394149a4a9" - integrity sha512-fIdPbxzMVq10wt3ou1lp3/f9n5ciHZ6t+P1vyGy3XXr018AntTYM4eg24sNFcNq8SYDQwmhhoGdS58IlYBzfBw== + version "0.2.80" + resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.80.tgz#97f560c1240b947b825f9923fdfa45c1b4bd7cb8" + integrity sha512-1yVb13p19DrgbL7M/zQmRe/5tQrm37QlCHOssk+G8Q9qnZBh6Azfk876zhaxmKqyMnFGbQqBjH+CV0zkpr+TTw== dependencies: isomorphic.js "^0.2.4" @@ -7569,6 +7650,15 @@ postcss@^8.4.14, postcss@^8.4.21, postcss@^8.4.23: picocolors "^1.0.0" source-map-js "^1.0.2" +postcss@^8.4.21: + version "8.4.27" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.27.tgz#234d7e4b72e34ba5a92c29636734349e0d9c3057" + integrity sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ== + dependencies: + nanoid "^3.3.6" + picocolors "^1.0.0" + source-map-js "^1.0.2" + prebuild-install@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.1.tgz#de97d5b34a70a0c81334fd24641f2a1702352e45" @@ -8619,6 +8709,11 @@ streamx@^2.15.0: fast-fifo "^1.1.0" queue-tick "^1.0.1" +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -9049,6 +9144,11 @@ tslib@~2.5.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.3.tgz#24944ba2d990940e6e982c4bea147aba80209913" integrity sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w== +tslib@^2.5.0, tslib@^2.6.0: + version "2.6.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.1.tgz#fd8c9a0ff42590b25703c0acb3de3d3f4ede0410" + integrity sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"