diff --git a/README.md b/README.md index 0e893ebe3..20e34b673 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

- Plane Logo + Plane Logo

@@ -11,22 +11,22 @@

-Discord +Discord online members -Discord +Commit activity per month

Plane Screens Plane Screens @@ -86,7 +86,7 @@ docker compose up -d

Plane Views @@ -95,7 +95,7 @@ docker compose up -d

Plane Issue Details @@ -104,7 +104,7 @@ docker compose up -d

Plane Cycles and Modules @@ -113,7 +113,7 @@ docker compose up -d

Plane Analytics @@ -122,7 +122,7 @@ docker compose up -d

Plane Pages @@ -132,7 +132,7 @@ docker compose up -d

Plane Command Menu diff --git a/apiserver/plane/api/permissions/__init__.py b/apiserver/plane/api/permissions/__init__.py index 91b3aea35..8b15a9373 100644 --- a/apiserver/plane/api/permissions/__init__.py +++ b/apiserver/plane/api/permissions/__init__.py @@ -1,2 +1,2 @@ -from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission +from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission, WorkspaceViewerPermission from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission diff --git a/apiserver/plane/api/permissions/workspace.py b/apiserver/plane/api/permissions/workspace.py index 7fccc455e..d01b545ee 100644 --- a/apiserver/plane/api/permissions/workspace.py +++ b/apiserver/plane/api/permissions/workspace.py @@ -61,3 +61,13 @@ class WorkspaceEntityPermission(BasePermission): return WorkspaceMember.objects.filter( member=request.user, workspace__slug=view.workspace_slug ).exists() + + +class WorkspaceViewerPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, workspace__slug=view.workspace_slug, role__gte=10 + ).exists() diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 2ff210f98..2d38b1139 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -25,7 +25,7 @@ from .project import ( ) from .state import StateSerializer, StateLiteSerializer from .view import IssueViewSerializer, IssueViewFavoriteSerializer -from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer +from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer from .asset import FileAssetSerializer from .issue import ( IssueCreateSerializer, @@ -43,6 +43,8 @@ from .issue import ( IssueLiteSerializer, IssueAttachmentSerializer, IssueSubscriberSerializer, + IssueReactionSerializer, + CommentReactionSerializer, ) from .module import ( diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 760f42dcc..5b7bb7598 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -12,6 +12,12 @@ from .workspace import WorkspaceLiteSerializer from .project import ProjectLiteSerializer from plane.db.models import Cycle, CycleIssue, CycleFavorite +class CycleWriteSerializer(BaseSerializer): + + class Meta: + model = Cycle + fields = "__all__" + class CycleSerializer(BaseSerializer): owned_by = UserLiteSerializer(read_only=True) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 7aeee7d70..770880ef0 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -29,6 +29,8 @@ from plane.db.models import ( ModuleIssue, IssueLink, IssueAttachment, + IssueReaction, + CommentReaction, ) @@ -50,6 +52,20 @@ class IssueFlatSerializer(BaseSerializer): ] +class IssueProjectLiteSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = Issue + fields = [ + "id", + "project_detail", + "name", + "sequence_id", + ] + read_only_fields = fields + + ##TODO: Find a better way to write this serializer ## Find a better approach to save manytomany? class IssueCreateSerializer(BaseSerializer): @@ -101,8 +117,15 @@ class IssueCreateSerializer(BaseSerializer): labels = validated_data.pop("labels_list", None) blocks = validated_data.pop("blocks_list", None) - project = self.context["project"] - issue = Issue.objects.create(**validated_data, project=project) + project_id = self.context["project_id"] + workspace_id = self.context["workspace_id"] + default_assignee_id = self.context["default_assignee_id"] + + issue = Issue.objects.create(**validated_data, project_id=project_id) + + # Issue Audit Users + created_by_id = issue.created_by_id + updated_by_id = issue.updated_by_id if blockers is not None and len(blockers): IssueBlocker.objects.bulk_create( @@ -110,10 +133,10 @@ class IssueCreateSerializer(BaseSerializer): IssueBlocker( block=issue, blocked_by=blocker, - project=project, - workspace=project.workspace, - created_by=issue.created_by, - updated_by=issue.updated_by, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, ) for blocker in blockers ], @@ -126,10 +149,10 @@ class IssueCreateSerializer(BaseSerializer): IssueAssignee( assignee=user, issue=issue, - project=project, - workspace=project.workspace, - created_by=issue.created_by, - updated_by=issue.updated_by, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, ) for user in assignees ], @@ -137,14 +160,14 @@ class IssueCreateSerializer(BaseSerializer): ) else: # Then assign it to default assignee - if project.default_assignee is not None: + if default_assignee_id is not None: IssueAssignee.objects.create( - assignee=project.default_assignee, + assignee_id=default_assignee_id, issue=issue, - project=project, - workspace=project.workspace, - created_by=issue.created_by, - updated_by=issue.updated_by, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, ) if labels is not None and len(labels): @@ -153,10 +176,10 @@ class IssueCreateSerializer(BaseSerializer): IssueLabel( label=label, issue=issue, - project=project, - workspace=project.workspace, - created_by=issue.created_by, - updated_by=issue.updated_by, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, ) for label in labels ], @@ -169,10 +192,10 @@ class IssueCreateSerializer(BaseSerializer): IssueBlocker( block=block, blocked_by=issue, - project=project, - workspace=project.workspace, - created_by=issue.created_by, - updated_by=issue.updated_by, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, ) for block in blocks ], @@ -187,6 +210,12 @@ class IssueCreateSerializer(BaseSerializer): labels = validated_data.pop("labels_list", None) blocks = validated_data.pop("blocks_list", None) + # Related models + project_id = instance.project_id + workspace_id = instance.workspace_id + created_by_id = instance.created_by_id + updated_by_id = instance.updated_by_id + if blockers is not None: IssueBlocker.objects.filter(block=instance).delete() IssueBlocker.objects.bulk_create( @@ -194,10 +223,10 @@ class IssueCreateSerializer(BaseSerializer): IssueBlocker( block=instance, blocked_by=blocker, - project=instance.project, - workspace=instance.project.workspace, - created_by=instance.created_by, - updated_by=instance.updated_by, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, ) for blocker in blockers ], @@ -211,10 +240,10 @@ class IssueCreateSerializer(BaseSerializer): IssueAssignee( assignee=user, issue=instance, - project=instance.project, - workspace=instance.project.workspace, - created_by=instance.created_by, - updated_by=instance.updated_by, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, ) for user in assignees ], @@ -228,10 +257,10 @@ class IssueCreateSerializer(BaseSerializer): IssueLabel( label=label, issue=instance, - project=instance.project, - workspace=instance.project.workspace, - created_by=instance.created_by, - updated_by=instance.updated_by, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, ) for label in labels ], @@ -245,23 +274,25 @@ class IssueCreateSerializer(BaseSerializer): IssueBlocker( block=block, blocked_by=instance, - project=instance.project, - workspace=instance.project.workspace, - created_by=instance.created_by, - updated_by=instance.updated_by, + project_id=project_id, + workspace_id=workspace_id, + created_by_id=created_by_id, + updated_by_id=updated_by_id, ) for block in blocks ], batch_size=10, ) + # Time updation occues even when other related models are updated instance.updated_at = timezone.now() return super().update(instance, validated_data) class IssueActivitySerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + project_detail = ProjectLiteSerializer(read_only=True, source="project") class Meta: model = IssueActivity @@ -335,19 +366,31 @@ class IssueLabelSerializer(BaseSerializer): class BlockedIssueSerializer(BaseSerializer): - blocked_issue_detail = IssueFlatSerializer(source="block", read_only=True) + blocked_issue_detail = IssueProjectLiteSerializer(source="block", read_only=True) class Meta: model = IssueBlocker - fields = "__all__" + fields = [ + "blocked_issue_detail", + "blocked_by", + "block", + ] + read_only_fields = fields class BlockerIssueSerializer(BaseSerializer): - blocker_issue_detail = IssueFlatSerializer(source="blocked_by", read_only=True) + blocker_issue_detail = IssueProjectLiteSerializer( + source="blocked_by", read_only=True + ) class Meta: model = IssueBlocker - fields = "__all__" + fields = [ + "blocker_issue_detail", + "blocked_by", + "block", + ] + read_only_fields = fields class IssueAssigneeSerializer(BaseSerializer): @@ -460,6 +503,89 @@ class IssueAttachmentSerializer(BaseSerializer): ] +class IssueReactionSerializer(BaseSerializer): + class Meta: + model = IssueReaction + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + "actor", + ] + + +class IssueReactionLiteSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = IssueReaction + fields = [ + "id", + "reaction", + "issue", + "actor_detail", + ] + + +class CommentReactionLiteSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + + class Meta: + model = CommentReaction + fields = [ + "id", + "reaction", + "comment", + "actor_detail", + ] + + +class CommentReactionSerializer(BaseSerializer): + class Meta: + model = CommentReaction + fields = "__all__" + read_only_fields = ["workspace", "project", "comment", "actor"] + + + +class IssueCommentSerializer(BaseSerializer): + actor_detail = UserLiteSerializer(read_only=True, source="actor") + issue_detail = IssueFlatSerializer(read_only=True, source="issue") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") + comment_reactions = CommentReactionLiteSerializer(read_only=True, many=True) + + + class Meta: + model = IssueComment + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + +class IssueStateFlatSerializer(BaseSerializer): + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + + class Meta: + model = Issue + fields = [ + "id", + "sequence_id", + "name", + "state_detail", + "project_detail", + ] + + # Issue Serializer with state details class IssueStateSerializer(BaseSerializer): label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) @@ -479,7 +605,7 @@ class IssueStateSerializer(BaseSerializer): class IssueSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(read_only=True, source="project") state_detail = StateSerializer(read_only=True, source="state") - parent_detail = IssueFlatSerializer(read_only=True, source="parent") + parent_detail = IssueStateFlatSerializer(read_only=True, source="parent") label_details = LabelSerializer(read_only=True, source="labels", many=True) assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) # List of issues blocked by this issue @@ -491,6 +617,7 @@ class IssueSerializer(BaseSerializer): issue_link = IssueLinkSerializer(read_only=True, many=True) issue_attachment = IssueAttachmentSerializer(read_only=True, many=True) sub_issues_count = serializers.IntegerField(read_only=True) + issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True) class Meta: model = Issue @@ -516,6 +643,7 @@ class IssueLiteSerializer(BaseSerializer): module_id = serializers.UUIDField(read_only=True) attachment_count = serializers.IntegerField(read_only=True) link_count = serializers.IntegerField(read_only=True) + issue_reactions = IssueReactionLiteSerializer(read_only=True, many=True) class Meta: model = Issue diff --git a/apiserver/plane/api/serializers/page.py b/apiserver/plane/api/serializers/page.py index 1eafe8966..94f7836de 100644 --- a/apiserver/plane/api/serializers/page.py +++ b/apiserver/plane/api/serializers/page.py @@ -3,7 +3,7 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer -from .issue import IssueFlatSerializer, LabelSerializer +from .issue import IssueFlatSerializer, LabelLiteSerializer from .workspace import WorkspaceLiteSerializer from .project import ProjectLiteSerializer from plane.db.models import Page, PageBlock, PageFavorite, PageLabel, Label @@ -23,16 +23,22 @@ class PageBlockSerializer(BaseSerializer): "page", ] +class PageBlockLiteSerializer(BaseSerializer): + + class Meta: + model = PageBlock + fields = "__all__" + class PageSerializer(BaseSerializer): is_favorite = serializers.BooleanField(read_only=True) - label_details = LabelSerializer(read_only=True, source="labels", many=True) + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) labels_list = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), write_only=True, required=False, ) - blocks = PageBlockSerializer(read_only=True, many=True) + blocks = PageBlockLiteSerializer(read_only=True, many=True) project_detail = ProjectLiteSerializer(source="project", read_only=True) workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True) diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 641edb07c..fa97c5a6d 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -93,6 +93,7 @@ class ProjectDetailSerializer(BaseSerializer): total_cycles = serializers.IntegerField(read_only=True) total_modules = serializers.IntegerField(read_only=True) is_member = serializers.BooleanField(read_only=True) + sort_order = serializers.FloatField(read_only=True) class Meta: model = Project diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 04bbc2a47..c8b5e7b5e 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -45,6 +45,11 @@ from plane.api.views import ( UserIssueCompletedGraphEndpoint, UserWorkspaceDashboardEndpoint, WorkspaceThemeViewSet, + WorkspaceUserProfileStatsEndpoint, + WorkspaceUserActivityEndpoint, + WorkspaceUserProfileEndpoint, + WorkspaceUserProfileIssuesEndpoint, + WorkspaceLabelsEndpoint, ## End Workspaces # File Assets FileAssetEndpoint, @@ -79,6 +84,8 @@ from plane.api.views import ( IssueAttachmentEndpoint, IssueArchiveViewSet, IssueSubscriberViewSet, + IssueReactionViewSet, + CommentReactionViewSet, ## End Issues # States StateViewSet, @@ -385,6 +392,31 @@ urlpatterns = [ ), name="workspace-themes", ), + path( + "workspaces//user-stats//", + WorkspaceUserProfileStatsEndpoint.as_view(), + name="workspace-user-stats", + ), + path( + "workspaces//user-activity//", + WorkspaceUserActivityEndpoint.as_view(), + name="workspace-user-activity", + ), + path( + "workspaces//user-profile//", + WorkspaceUserProfileEndpoint.as_view(), + name="workspace-user-profile-page", + ), + path( + "workspaces//user-issues//", + WorkspaceUserProfileIssuesEndpoint.as_view(), + name="workspace-user-profile-issues", + ), + path( + "workspaces//labels/", + WorkspaceLabelsEndpoint.as_view(), + name="workspace-labels", + ), ## End Workspaces ## # Projects path( @@ -836,6 +868,48 @@ urlpatterns = [ name="project-issue-subscribers", ), ## End Issue Subscribers + # Issue Reactions + path( + "workspaces//projects//issues//reactions/", + IssueReactionViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-reactions", + ), + path( + "workspaces//projects//issues//reactions//", + IssueReactionViewSet.as_view( + { + "delete": "destroy", + } + ), + name="project-issue-reactions", + ), + ## End Issue Reactions + # Comment Reactions + path( + "workspaces//projects//comments//reactions/", + CommentReactionViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-comment-reactions", + ), + path( + "workspaces//projects//comments//reactions//", + CommentReactionViewSet.as_view( + { + "delete": "destroy", + } + ), + name="project-issue-comment-reactions", + ), + ## End Comment Reactions ## IssueProperty path( "workspaces//projects//issue-properties/", @@ -1240,7 +1314,7 @@ urlpatterns = [ ## End Importer # Search path( - "workspaces//projects//search/", + "workspaces//search/", GlobalSearchEndpoint.as_view(), name="global-search", ), diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 076cdd006..75509a16c 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -42,6 +42,11 @@ from .workspace import ( UserIssueCompletedGraphEndpoint, UserWorkspaceDashboardEndpoint, WorkspaceThemeViewSet, + WorkspaceUserProfileStatsEndpoint, + WorkspaceUserActivityEndpoint, + WorkspaceUserProfileEndpoint, + WorkspaceUserProfileIssuesEndpoint, + WorkspaceLabelsEndpoint, ) from .state import StateViewSet from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet @@ -68,6 +73,8 @@ from .issue import ( IssueAttachmentEndpoint, IssueArchiveViewSet, IssueSubscriberViewSet, + CommentReactionViewSet, + IssueReactionViewSet, ) from .auth_extended import ( diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index 0d37b1c33..aa8ff4511 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -279,6 +279,8 @@ class MagicSignInGenerateEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + # Clean up + email = email.strip().lower() validate_email(email) ## Generate a random token @@ -346,7 +348,7 @@ class MagicSignInEndpoint(BaseAPIView): def post(self, request): try: user_token = request.data.get("token", "").strip() - key = request.data.get("key", False) + key = request.data.get("key", False).strip().lower() if not key or user_token == "": return Response( diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index d78333528..268485b6e 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -31,6 +31,7 @@ from plane.api.serializers import ( CycleIssueSerializer, CycleFavoriteSerializer, IssueStateSerializer, + CycleWriteSerializer, ) from plane.api.permissions import ProjectEntityPermission from plane.db.models import ( @@ -338,7 +339,7 @@ class CycleViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - serializer = CycleSerializer(cycle, data=request.data, partial=True) + serializer = CycleWriteSerializer(cycle, data=request.data, partial=True) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) @@ -691,7 +692,6 @@ class CycleDateCheckEndpoint(BaseAPIView): return Response( { "error": "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates", - "cycles": CycleSerializer(cycles, many=True).data, "status": False, } ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index aab926fd2..9369ccf2b 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -46,8 +46,11 @@ from plane.api.serializers import ( IssueAttachmentSerializer, IssueSubscriberSerializer, ProjectMemberLiteSerializer, + IssueReactionSerializer, + CommentReactionSerializer, ) from plane.api.permissions import ( + WorkspaceEntityPermission, ProjectEntityPermission, WorkSpaceAdminPermission, ProjectMemberPermission, @@ -65,6 +68,8 @@ from plane.db.models import ( State, IssueSubscriber, ProjectMember, + IssueReaction, + CommentReaction, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -151,13 +156,19 @@ class IssueViewSet(BaseViewSet): .select_related("parent") .prefetch_related("assignees") .prefetch_related("labels") + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) ) @method_decorator(gzip_page) def list(self, request, slug, project_id): try: filters = issue_filters(request.query_params, "GET") - show_sub_issues = request.GET.get("show_sub_issues", "true") + print(filters) # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", None] @@ -244,12 +255,6 @@ class IssueViewSet(BaseViewSet): else: issue_queryset = issue_queryset.order_by(order_by_param) - issue_queryset = ( - issue_queryset - if show_sub_issues == "true" - else issue_queryset.filter(parent__isnull=True) - ) - issues = IssueLiteSerializer(issue_queryset, many=True).data ## Grouping the results @@ -262,7 +267,7 @@ class IssueViewSet(BaseViewSet): return Response(issues, status=status.HTTP_200_OK) except Exception as e: - print(e) + capture_exception(e) return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, @@ -270,9 +275,15 @@ class IssueViewSet(BaseViewSet): def create(self, request, slug, project_id): try: - project = Project.objects.get(workspace__slug=slug, pk=project_id) + project = Project.objects.get(pk=project_id) + serializer = IssueCreateSerializer( - data=request.data, context={"project": project} + data=request.data, + context={ + "project_id": project_id, + "workspace_id": project.workspace_id, + "default_assignee_id": project.default_assignee_id, + }, ) if serializer.is_valid(): @@ -311,9 +322,17 @@ class UserWorkSpaceIssues(BaseAPIView): @method_decorator(gzip_page) def get(self, request, slug): try: - issues = ( + 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.filter( - assignees__in=[request.user], workspace__slug=slug + (Q(assignees__in=[request.user]) | Q(created_by=request.user)), + workspace__slug=slug, ) .annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) @@ -327,7 +346,7 @@ class UserWorkSpaceIssues(BaseAPIView): .select_related("parent") .prefetch_related("assignees") .prefetch_related("labels") - .order_by("-created_at") + .order_by(order_by_param) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -342,9 +361,77 @@ class UserWorkSpaceIssues(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .filter(**filters) ) - serializer = IssueLiteSerializer(issues, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + + # 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 + + ## Grouping the results + group_by = request.GET.get("group_by", False) + if group_by: + return Response( + group_results(issues, group_by), status=status.HTTP_200_OK + ) + + return Response(issues, status=status.HTTP_200_OK) except Exception as e: capture_exception(e) return Response( @@ -390,12 +477,13 @@ class IssueActivityEndpoint(BaseAPIView): ~Q(field="comment"), project__project_projectmember__member=self.request.user, ) - .select_related("actor", "workspace") + .select_related("actor", "workspace", "issue", "project") ).order_by("created_at") issue_comments = ( IssueComment.objects.filter(issue_id=issue_id) .filter(project__project_projectmember__member=self.request.user) .order_by("created_at") + .select_related("actor", "issue", "project", "workspace") ) issue_activities = IssueActivitySerializer(issue_activities, many=True).data issue_comments = IssueCommentSerializer(issue_comments, many=True).data @@ -418,7 +506,7 @@ class IssueCommentViewSet(BaseViewSet): serializer_class = IssueCommentSerializer model = IssueComment permission_classes = [ - ProjectEntityPermission, + ProjectLitePermission, ] filterset_fields = [ @@ -628,9 +716,7 @@ class SubIssuesEndpoint(BaseAPIView): def get(self, request, slug, project_id, issue_id): try: sub_issues = ( - Issue.issue_objects.filter( - parent_id=issue_id, workspace__slug=slug, project_id=project_id - ) + Issue.issue_objects.filter(parent_id=issue_id, workspace__slug=slug) .select_related("project") .select_related("workspace") .select_related("state") @@ -660,9 +746,7 @@ class SubIssuesEndpoint(BaseAPIView): ) state_distribution = ( - State.objects.filter( - ~Q(name="Triage"), workspace__slug=slug, project_id=project_id - ) + State.objects.filter(~Q(name="Triage"), workspace__slug=slug) .annotate( state_count=Count( "state_issue", @@ -1096,7 +1180,8 @@ class IssueArchiveViewSet(BaseViewSet): return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) except Issue.DoesNotExist: return Response( - {"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND) + {"error": "Issue Does not exist"}, status=status.HTTP_404_NOT_FOUND + ) except Exception as e: capture_exception(e) return Response( @@ -1104,6 +1189,7 @@ class IssueArchiveViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + class IssueSubscriberViewSet(BaseViewSet): serializer_class = IssueSubscriberSerializer model = IssueSubscriber @@ -1144,18 +1230,22 @@ class IssueSubscriberViewSet(BaseViewSet): def list(self, request, slug, project_id, issue_id): try: - members = ProjectMember.objects.filter( - workspace__slug=slug, project_id=project_id - ).annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - workspace__slug=slug, - project_id=project_id, - issue_id=issue_id, - subscriber=OuterRef("member"), + members = ( + ProjectMember.objects.filter( + workspace__slug=slug, project_id=project_id + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + subscriber=OuterRef("member"), + ) ) ) - ).select_related("member") + .select_related("member") + ) serializer = ProjectMemberLiteSerializer(members, many=True) return Response(serializer.data, status=status.HTTP_200_OK) except Exception as e: @@ -1255,3 +1345,103 @@ class IssueSubscriberViewSet(BaseViewSet): {"error": "Something went wrong, please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class IssueReactionViewSet(BaseViewSet): + serializer_class = IssueReactionSerializer + model = IssueReaction + permission_classes = [ + ProjectLitePermission, + ] + + def get_queryset(self): + 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")) + .filter(project__project_projectmember__member=self.request.user) + .order_by("-created_at") + .distinct() + ) + + def perform_create(self, serializer): + serializer.save( + issue_id=self.kwargs.get("issue_id"), + project_id=self.kwargs.get("project_id"), + actor=self.request.user, + ) + + def destroy(self, request, slug, project_id, issue_id, reaction_code): + try: + issue_reaction = IssueReaction.objects.get( + workspace__slug=slug, + project_id=project_id, + 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 CommentReactionViewSet(BaseViewSet): + serializer_class = CommentReactionSerializer + model = CommentReaction + permission_classes = [ + ProjectLitePermission, + ] + + def get_queryset(self): + 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")) + .filter(project__project_projectmember__member=self.request.user) + .order_by("-created_at") + .distinct() + ) + + def perform_create(self, serializer): + serializer.save( + actor=self.request.user, + comment_id=self.kwargs.get("comment_id"), + project_id=self.kwargs.get("project_id"), + ) + + def destroy(self, request, slug, project_id, comment_id, reaction_code): + try: + comment_reaction = CommentReaction.objects.get( + workspace__slug=slug, + project_id=project_id, + 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, + ) diff --git a/apiserver/plane/api/views/notification.py b/apiserver/plane/api/views/notification.py index b7f5bd335..2abc82631 100644 --- a/apiserver/plane/api/views/notification.py +++ b/apiserver/plane/api/views/notification.py @@ -6,14 +6,15 @@ from django.utils import timezone from rest_framework import status from rest_framework.response import Response from sentry_sdk import capture_exception +from plane.utils.paginator import BasePaginator # Module imports from .base import BaseViewSet, BaseAPIView -from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue +from plane.db.models import Notification, IssueAssignee, IssueSubscriber, Issue, WorkspaceMember from plane.api.serializers import NotificationSerializer -class NotificationViewSet(BaseViewSet): +class NotificationViewSet(BaseViewSet, BasePaginator): model = Notification serializer_class = NotificationSerializer @@ -37,9 +38,13 @@ class NotificationViewSet(BaseViewSet): # Filter type type = request.GET.get("type", "all") - notifications = Notification.objects.filter( - workspace__slug=slug, receiver_id=request.user.id - ).order_by("snoozed_till", "-created_at") + notifications = ( + Notification.objects.filter( + workspace__slug=slug, receiver_id=request.user.id + ) + .select_related("workspace", "project", "triggered_by", "receiver") + .order_by("snoozed_till", "-created_at") + ) # Filter for snoozed notifications if snoozed == "false": @@ -78,10 +83,23 @@ class NotificationViewSet(BaseViewSet): # Created issues if type == "created": - issue_ids = Issue.objects.filter( - workspace__slug=slug, created_by=request.user - ).values_list("pk", flat=True) - notifications = notifications.filter(entity_identifier__in=issue_ids) + if WorkspaceMember.objects.filter(workspace__slug=slug, member=request.user, role__lt=15).exists(): + notifications = Notification.objects.none() + else: + issue_ids = Issue.objects.filter( + workspace__slug=slug, created_by=request.user + ).values_list("pk", flat=True) + notifications = notifications.filter(entity_identifier__in=issue_ids) + + # Pagination + if request.GET.get("per_page", False) and request.GET.get("cursor", False): + return self.paginate( + request=request, + queryset=(notifications), + on_results=lambda notifications: NotificationSerializer( + notifications, many=True + ).data, + ) serializer = NotificationSerializer(notifications, many=True) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/people.py b/apiserver/plane/api/views/people.py index 705f5c96e..84ee47e42 100644 --- a/apiserver/plane/api/views/people.py +++ b/apiserver/plane/api/views/people.py @@ -140,7 +140,7 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator): def get(self, request): try: queryset = IssueActivity.objects.filter(actor=request.user).select_related( - "actor", "workspace" + "actor", "workspace", "issue", "project" ) return self.paginate( diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 5c6ea3fd1..0589f4a61 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -5,7 +5,7 @@ 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 +from django.db.models import Q, Exists, OuterRef, Func, F, Min, Subquery from django.core.validators import validate_email from django.conf import settings @@ -91,6 +91,24 @@ class ProjectViewSet(BaseViewSet): ) ) ) + .annotate( + total_members=ProjectMember.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + total_cycles=Cycle.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + total_modules=Module.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) .distinct() ) @@ -102,10 +120,16 @@ class ProjectViewSet(BaseViewSet): project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), ) + sort_order_query = ProjectMember.objects.filter( + member=request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ).values("sort_order") projects = ( self.get_queryset() .annotate(is_favorite=Exists(subquery)) - .order_by("-is_favorite", "name") + .annotate(sort_order=Subquery(sort_order_query)) + .order_by("sort_order", "name") .annotate( total_members=ProjectMember.objects.filter( project_id=OuterRef("id") @@ -152,10 +176,17 @@ class ProjectViewSet(BaseViewSet): serializer.save() # Add the user as Administrator to the project - ProjectMember.objects.create( + project_member = ProjectMember.objects.create( project_id=serializer.data["id"], member=request.user, role=20 ) + if serializer.data["project_lead"] is not None: + ProjectMember.objects.create( + project_id=serializer.data["id"], + member_id=serializer.data["project_lead"], + role=20, + ) + # Default states states = [ { @@ -207,9 +238,11 @@ class ProjectViewSet(BaseViewSet): ] ) - return Response(serializer.data, status=status.HTTP_201_CREATED) + data = serializer.data + data["sort_order"] = project_member.sort_order + return Response(data, status=status.HTTP_201_CREATED) return Response( - [serializer.errors[error][0] for error in serializer.errors], + serializer.errors, status=status.HTTP_400_BAD_REQUEST, ) except IntegrityError as e: @@ -567,17 +600,26 @@ class AddMemberToProjectEndpoint(BaseAPIView): {"error": "Atleast one member is required"}, status=status.HTTP_400_BAD_REQUEST, ) + bulk_project_members = [] - project_members = ProjectMember.objects.bulk_create( - [ + project_members = ProjectMember.objects.filter( + workspace=self.workspace, member_id__in=[member.get("member_id") for member in members] + ).values("member_id").annotate(sort_order_min=Min("sort_order")) + + for member in members: + sort_order = [project_member.get("sort_order") for project_member in project_members] + bulk_project_members.append( ProjectMember( member_id=member.get("member_id"), role=member.get("role", 10), project_id=project_id, workspace_id=project.workspace_id, + sort_order=sort_order[0] - 10000 if len(sort_order) else 65535 ) - for member in members - ], + ) + + project_members = ProjectMember.objects.bulk_create( + bulk_project_members, batch_size=10, ignore_conflicts=True, ) @@ -819,11 +861,15 @@ class ProjectUserViewsEndpoint(BaseAPIView): view_props = project_member.view_props default_props = project_member.default_props + preferences = project_member.preferences + sort_order = project_member.sort_order project_member.view_props = request.data.get("view_props", view_props) project_member.default_props = request.data.get( "default_props", default_props ) + project_member.preferences = request.data.get("preferences", preferences) + project_member.sort_order = request.data.get("sort_order", sort_order) project_member.save() diff --git a/apiserver/plane/api/views/search.py b/apiserver/plane/api/views/search.py index 51925dd7b..0a8c5c530 100644 --- a/apiserver/plane/api/views/search.py +++ b/apiserver/plane/api/views/search.py @@ -20,7 +20,7 @@ class GlobalSearchEndpoint(BaseAPIView): also show related workspace if found """ - def filter_workspaces(self, query, slug, project_id): + def filter_workspaces(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() for field in fields: @@ -31,8 +31,8 @@ class GlobalSearchEndpoint(BaseAPIView): .values("name", "id", "slug") ) - def filter_projects(self, query, slug, project_id): - fields = ["name"] + def filter_projects(self, query, slug, project_id, workspace_search): + fields = ["name", "identifier"] q = Q() for field in fields: q |= Q(**{f"{field}__icontains": query}) @@ -46,8 +46,8 @@ class GlobalSearchEndpoint(BaseAPIView): .values("name", "id", "identifier", "workspace__slug") ) - def filter_issues(self, query, slug, project_id): - fields = ["name", "sequence_id"] + def filter_issues(self, query, slug, project_id, workspace_search): + fields = ["name", "sequence_id", "project__identifier"] q = Q() for field in fields: if field == "sequence_id": @@ -56,111 +56,123 @@ class GlobalSearchEndpoint(BaseAPIView): q |= Q(**{"sequence_id": sequence_id}) else: q |= Q(**{f"{field}__icontains": query}) - return ( - Issue.issue_objects.filter( - q, - project__project_projectmember__member=self.request.user, - workspace__slug=slug, - project_id=project_id, - ) - .distinct() - .values( - "name", - "id", - "sequence_id", - "project__identifier", - "project_id", - "workspace__slug", - ) + + issues = Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + workspace__slug=slug, ) - def filter_cycles(self, query, slug, project_id): + if workspace_search == "false" and project_id: + issues = issues.filter(project_id=project_id) + + return issues.distinct().values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "workspace__slug", + ) + + def filter_cycles(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() for field in fields: q |= Q(**{f"{field}__icontains": query}) - return ( - Cycle.objects.filter( - q, - project__project_projectmember__member=self.request.user, - workspace__slug=slug, - project_id=project_id, - ) - .distinct() - .values( - "name", - "id", - "project_id", - "workspace__slug", - ) + + cycles = Cycle.objects.filter( + q, + project__project_projectmember__member=self.request.user, + workspace__slug=slug, ) - def filter_modules(self, query, slug, project_id): + if workspace_search == "false" and project_id: + cycles = cycles.filter(project_id=project_id) + + return cycles.distinct().values( + "name", + "id", + "project_id", + "project__identifier", + "workspace__slug", + ) + + def filter_modules(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() for field in fields: q |= Q(**{f"{field}__icontains": query}) - return ( - Module.objects.filter( - q, - project__project_projectmember__member=self.request.user, - workspace__slug=slug, - project_id=project_id, - ) - .distinct() - .values( - "name", - "id", - "project_id", - "workspace__slug", - ) + + modules = Module.objects.filter( + q, + project__project_projectmember__member=self.request.user, + workspace__slug=slug, ) - def filter_pages(self, query, slug, project_id): + if workspace_search == "false" and project_id: + modules = modules.filter(project_id=project_id) + + return modules.distinct().values( + "name", + "id", + "project_id", + "project__identifier", + "workspace__slug", + ) + + def filter_pages(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() for field in fields: q |= Q(**{f"{field}__icontains": query}) - return ( - Page.objects.filter( - q, - project__project_projectmember__member=self.request.user, - workspace__slug=slug, - project_id=project_id, - ) - .distinct() - .values( - "name", - "id", - "project_id", - "workspace__slug", - ) + + pages = Page.objects.filter( + q, + project__project_projectmember__member=self.request.user, + workspace__slug=slug, ) - def filter_views(self, query, slug, project_id): + if workspace_search == "false" and project_id: + pages = pages.filter(project_id=project_id) + + return pages.distinct().values( + "name", + "id", + "project_id", + "project__identifier", + "workspace__slug", + ) + + def filter_views(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() for field in fields: q |= Q(**{f"{field}__icontains": query}) - return ( - IssueView.objects.filter( - q, - project__project_projectmember__member=self.request.user, - workspace__slug=slug, - project_id=project_id, - ) - .distinct() - .values( - "name", - "id", - "project_id", - "workspace__slug", - ) + + issue_views = IssueView.objects.filter( + q, + project__project_projectmember__member=self.request.user, + workspace__slug=slug, ) - def get(self, request, slug, project_id): + if workspace_search == "false" and project_id: + issue_views = issue_views.filter(project_id=project_id) + + return issue_views.distinct().values( + "name", + "id", + "project_id", + "project__identifier", + "workspace__slug", + ) + + def get(self, request, slug): try: query = request.query_params.get("search", False) + workspace_search = request.query_params.get("workspace_search", "false") + project_id = request.query_params.get("project_id", False) + if not query: return Response( { @@ -191,7 +203,7 @@ class GlobalSearchEndpoint(BaseAPIView): for model in MODELS_MAPPER.keys(): func = MODELS_MAPPER.get(model, None) - results[model] = func(query, slug, project_id) + results[model] = func(query, slug, project_id, workspace_search) return Response({"results": results}, status=status.HTTP_200_OK) except Exception as e: @@ -206,6 +218,7 @@ class IssueSearchEndpoint(BaseAPIView): def get(self, request, slug, project_id): try: query = request.query_params.get("search", False) + workspace_search = request.query_params.get("workspace_search", "false") parent = request.query_params.get("parent", "false") blocker_blocked_by = request.query_params.get("blocker_blocked_by", "false") cycle = request.query_params.get("cycle", "false") @@ -216,10 +229,12 @@ class IssueSearchEndpoint(BaseAPIView): issues = Issue.issue_objects.filter( workspace__slug=slug, - project_id=project_id, project__project_projectmember__member=self.request.user, ) + if workspace_search == "false": + issues = issues.filter(project_id=project_id) + if query: issues = search_issues(query, issues) @@ -251,12 +266,12 @@ class IssueSearchEndpoint(BaseAPIView): if module == "true": issues = issues.exclude(issue_module__isnull=False) - return Response( issues.values( "name", "id", "sequence_id", + "project__name", "project__identifier", "project_id", "workspace__slug", diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 305deb525..b195cedb1 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -13,12 +13,18 @@ from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.contrib.sites.shortcuts import get_current_site from django.db.models import ( - CharField, - Count, + Prefetch, OuterRef, Func, F, Q, + Count, + Case, + Value, + CharField, + When, + Max, + IntegerField, ) from django.db.models.functions import ExtractWeek, Cast, ExtractDay from django.db.models.fields import DateField @@ -39,6 +45,8 @@ from plane.api.serializers import ( UserLiteSerializer, ProjectMemberSerializer, WorkspaceThemeSerializer, + IssueActivitySerializer, + IssueLiteSerializer, ) from plane.api.views.base import BaseAPIView from . import BaseViewSet @@ -60,9 +68,23 @@ from plane.db.models import ( PageFavorite, Page, IssueViewFavorite, + IssueLink, + IssueAttachment, + IssueSubscriber, + Project, + Label, + WorkspaceMember, + CycleIssue, +) +from plane.api.permissions import ( + WorkSpaceBasePermission, + WorkSpaceAdminPermission, + WorkspaceEntityPermission, + WorkspaceViewerPermission, ) -from plane.api.permissions import WorkSpaceBasePermission, WorkSpaceAdminPermission from plane.bgtasks.workspace_invitation_task import workspace_invitation +from plane.utils.issue_filters import issue_filters +from plane.utils.grouper import group_results class WorkSpaceViewSet(BaseViewSet): @@ -597,6 +619,19 @@ class WorkSpaceMemberViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + # Check for the only member in the workspace + if ( + workspace_member.role == 20 + and WorkspaceMember.objects.filter( + workspace__slug=slug, role=20 + ).count() + == 1 + ): + return Response( + {"error": "Cannot delete the only Admin for the workspace"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Delete the user also from all the projects ProjectMember.objects.filter( workspace__slug=slug, member=workspace_member.member @@ -1009,3 +1044,391 @@ class WorkspaceThemeViewSet(BaseViewSet): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class WorkspaceUserProfileStatsEndpoint(BaseAPIView): + + def get(self, request, slug, user_id): + try: + filters = issue_filters(request.query_params, "GET") + + state_distribution = ( + Issue.issue_objects.filter( + workspace__slug=slug, + assignees__in=[user_id], + project__project_projectmember__member=request.user, + ) + .filter(**filters) + .annotate(state_group=F("state__group")) + .values("state_group") + .annotate(state_count=Count("state_group")) + .order_by("state_group") + ) + + priority_order = ["urgent", "high", "medium", "low", None] + + priority_distribution = ( + Issue.objects.filter( + workspace__slug=slug, + assignees__in=[user_id], + project__project_projectmember__member=request.user, + ) + .filter(**filters) + .values("priority") + .annotate(priority_count=Count("priority")) + .annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + default=Value(len(priority_order)), + output_field=IntegerField(), + ) + ) + .order_by("priority_order") + ) + + created_issues = ( + Issue.issue_objects.filter( + workspace__slug=slug, + assignees__in=[user_id], + project__project_projectmember__member=request.user, + created_by_id=user_id, + ) + .filter(**filters) + .count() + ) + + assigned_issues_count = ( + Issue.issue_objects.filter( + workspace__slug=slug, + assignees__in=[user_id], + project__project_projectmember__member=request.user, + ) + .filter(**filters) + .count() + ) + + pending_issues_count = ( + Issue.issue_objects.filter( + ~Q(state__group__in=["completed", "cancelled"]), + workspace__slug=slug, + assignees__in=[user_id], + project__project_projectmember__member=request.user, + ) + .filter(**filters) + .count() + ) + + completed_issues_count = ( + Issue.issue_objects.filter( + workspace__slug=slug, + assignees__in=[user_id], + state__group="completed", + project__project_projectmember__member=request.user, + ) + .filter(**filters) + .count() + ) + + subscribed_issues_count = ( + IssueSubscriber.objects.filter( + workspace__slug=slug, + subscriber_id=user_id, + project__project_projectmember__member=request.user, + ) + .filter(**filters) + .count() + ) + + upcoming_cycles = CycleIssue.objects.filter( + workspace__slug=slug, + cycle__start_date__gt=timezone.now().date(), + issue__assignees__in=[user_id,] + ).values("cycle__name", "cycle__id", "cycle__project_id") + + present_cycle = CycleIssue.objects.filter( + workspace__slug=slug, + cycle__start_date__lt=timezone.now().date(), + cycle__end_date__gt=timezone.now().date(), + issue__assignees__in=[user_id,] + ).values("cycle__name", "cycle__id", "cycle__project_id") + + return Response( + { + "state_distribution": state_distribution, + "priority_distribution": priority_distribution, + "created_issues": created_issues, + "assigned_issues": assigned_issues_count, + "completed_issues": completed_issues_count, + "pending_issues": pending_issues_count, + "subscribed_issues": subscribed_issues_count, + "present_cycles": present_cycle, + "upcoming_cycles": upcoming_cycles, + } + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class WorkspaceUserActivityEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceEntityPermission, + ] + + def get(self, request, slug, user_id): + try: + + projects = request.query_params.getlist("project", []) + + queryset = IssueActivity.objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + actor=user_id, + ).select_related("actor", "workspace", "issue", "project") + + if projects: + queryset = queryset.filter(project__in=projects) + + return self.paginate( + request=request, + queryset=queryset, + on_results=lambda issue_activities: IssueActivitySerializer( + issue_activities, many=True + ).data, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class WorkspaceUserProfileEndpoint(BaseAPIView): + + def get(self, request, slug, user_id): + try: + user_data = User.objects.get(pk=user_id) + + requesting_workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user) + projects = [] + if requesting_workspace_member.role >= 10: + projects = ( + Project.objects.filter( + workspace__slug=slug, + project_projectmember__member=request.user, + ) + .annotate( + created_issues=Count( + "project_issue", filter=Q(project_issue__created_by_id=user_id) + ) + ) + .annotate( + assigned_issues=Count( + "project_issue", + filter=Q(project_issue__assignees__in=[user_id]), + ) + ) + .annotate( + completed_issues=Count( + "project_issue", + filter=Q( + project_issue__completed_at__isnull=False, + project_issue__assignees__in=[user_id], + ), + ) + ) + .annotate( + pending_issues=Count( + "project_issue", + filter=Q( + project_issue__state__group__in=[ + "backlog", + "unstarted", + "started", + ], + project_issue__assignees__in=[user_id], + ), + ) + ) + .values( + "id", + "name", + "identifier", + "emoji", + "icon_prop", + "created_issues", + "assigned_issues", + "completed_issues", + "pending_issues", + ) + ) + + return Response( + { + "project_data": projects, + "user_data": { + "email": user_data.email, + "first_name": user_data.first_name, + "last_name": user_data.last_name, + "avatar": user_data.avatar, + "cover_image": user_data.cover_image, + "date_joined": user_data.date_joined, + "user_timezone": user_data.user_timezone, + }, + }, + status=status.HTTP_200_OK, + ) + except WorkspaceMember.DoesNotExist: + return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceViewerPermission, + ] + + def get(self, request, slug, user_id): + try: + filters = issue_filters(request.query_params, "GET") + order_by_param = request.GET.get("order_by", "-created_at") + issue_queryset = ( + Issue.issue_objects.filter( + Q(assignees__in=[user_id]) + | Q(created_by_id=user_id) + | Q(issue_subscribers__subscriber_id=user_id), + workspace__slug=slug, + project__project_projectmember__member=request.user, + ) + .filter(**filters) + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .select_related("project", "workspace", "state", "parent") + .prefetch_related("assignees", "labels") + .order_by("-created_at") + .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") + ) + ).distinct() + + # 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 + + ## Grouping the results + group_by = request.GET.get("group_by", False) + if group_by: + return Response( + group_results(issues, group_by), status=status.HTTP_200_OK + ) + + return Response(issues, 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 WorkspaceLabelsEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceViewerPermission, + ] + + def get(self, request, slug): + try: + labels = Label.objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + ).values("parent", "name", "color", "id", "project_id", "workspace__slug") + return Response(labels, 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, + ) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 105a05b56..8f34daf52 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -70,7 +70,7 @@ def track_parent( issue_id=issue_id, actor=actor, verb="updated", - old_value=f"{project.identifier}-{old_parent.sequence_id}", + old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}", new_value=None, field="parent", project=project, @@ -88,10 +88,10 @@ def track_parent( issue_id=issue_id, actor=actor, verb="updated", - old_value=f"{project.identifier}-{old_parent.sequence_id}" + old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}" if old_parent is not None else None, - new_value=f"{project.identifier}-{new_parent.sequence_id}", + new_value=f"{new_parent.project.identifier}-{new_parent.sequence_id}", field="parent", project=project, workspace=project.workspace, @@ -376,7 +376,7 @@ def track_assignees( verb="updated", old_value=assignee.email, new_value="", - field="assignee", + field="assignees", project=project, workspace=project.workspace, comment=f"{actor.email} removed assignee {assignee.email}", @@ -415,11 +415,11 @@ def track_blocks( actor=actor, verb="updated", old_value="", - new_value=f"{project.identifier}-{issue.sequence_id}", + new_value=f"{issue.project.identifier}-{issue.sequence_id}", field="blocks", project=project, workspace=project.workspace, - comment=f"{actor.email} added blocking issue {project.identifier}-{issue.sequence_id}", + comment=f"{actor.email} added blocking issue {issue.project.identifier}-{issue.sequence_id}", new_identifier=issue.id, ) ) @@ -436,12 +436,12 @@ def track_blocks( issue_id=issue_id, actor=actor, verb="updated", - old_value=f"{project.identifier}-{issue.sequence_id}", + old_value=f"{issue.project.identifier}-{issue.sequence_id}", new_value="", field="blocks", project=project, workspace=project.workspace, - comment=f"{actor.email} removed blocking issue {project.identifier}-{issue.sequence_id}", + comment=f"{actor.email} removed blocking issue {issue.project.identifier}-{issue.sequence_id}", old_identifier=issue.id, ) ) @@ -477,11 +477,11 @@ def track_blockings( actor=actor, verb="updated", old_value="", - new_value=f"{project.identifier}-{issue.sequence_id}", + new_value=f"{issue.project.identifier}-{issue.sequence_id}", field="blocking", project=project, workspace=project.workspace, - comment=f"{actor.email} added blocked by issue {project.identifier}-{issue.sequence_id}", + comment=f"{actor.email} added blocked by issue {issue.project.identifier}-{issue.sequence_id}", new_identifier=issue.id, ) ) @@ -498,12 +498,12 @@ def track_blockings( issue_id=issue_id, actor=actor, verb="updated", - old_value=f"{project.identifier}-{issue.sequence_id}", + old_value=f"{issue.project.identifier}-{issue.sequence_id}", new_value="", field="blocking", project=project, workspace=project.workspace, - comment=f"{actor.email} removed blocked by issue {project.identifier}-{issue.sequence_id}", + comment=f"{actor.email} removed blocked by issue {issue.project.identifier}-{issue.sequence_id}", old_identifier=issue.id, ) ) @@ -959,6 +959,11 @@ def update_link_activity( def delete_link_activity( requested_data, current_instance, issue_id, project, actor, issue_activities ): + + current_instance = ( + json.loads(current_instance) if current_instance is not None else None + ) + issue_activities.append( IssueActivity( issue_id=issue_id, @@ -968,6 +973,8 @@ def delete_link_activity( verb="deleted", actor=actor, field="link", + old_value=current_instance.get("url", ""), + new_value="" ) ) @@ -989,7 +996,7 @@ def create_attachment_activity( verb="created", actor=actor, field="attachment", - new_value=current_instance.get("access", ""), + new_value=current_instance.get("asset", ""), new_identifier=current_instance.get("id", None), ) ) @@ -1034,11 +1041,14 @@ def issue_activity( "module.activity.created", "module.activity.deleted", ]: - issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first() + issue = Issue.objects.filter(pk=issue_id).first() if issue is not None: - issue.updated_at = timezone.now() - issue.save(update_fields=["updated_at"]) + try: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) + except Exception as e: + pass if subscriber: # add the user to issue subscriber @@ -1122,7 +1132,7 @@ def issue_activity( issue_subscribers = issue_subscribers + issue_assignees - issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first() + issue = Issue.objects.filter(pk=issue_id).first() # Add bot filtering if ( @@ -1149,7 +1159,7 @@ def issue_activity( "issue": { "id": str(issue_id), "name": str(issue.name), - "identifier": str(project.identifier), + "identifier": str(issue.project.identifier), "sequence_id": issue.sequence_id, "state_name": issue.state.name, "state_group": issue.state.group, diff --git a/apiserver/plane/db/migrations/0039_auto_20230723_2203.py b/apiserver/plane/db/migrations/0039_auto_20230723_2203.py new file mode 100644 index 000000000..5d5747543 --- /dev/null +++ b/apiserver/plane/db/migrations/0039_auto_20230723_2203.py @@ -0,0 +1,97 @@ +# Generated by Django 4.2.3 on 2023-07-23 16:33 +import random +from django.db import migrations, models +import plane.db.models.workspace + + +def rename_field(apps, schema_editor): + Model = apps.get_model("db", "IssueActivity") + updated_activity = [] + for obj in Model.objects.filter(field="assignee"): + obj.field = "assignees" + updated_activity.append(obj) + + Model.objects.bulk_update(updated_activity, ["field"], batch_size=100) + + +def update_workspace_member_props(apps, schema_editor): + Model = apps.get_model("db", "WorkspaceMember") + + updated_workspace_member = [] + + for obj in Model.objects.all(): + if obj.view_props is None: + obj.view_props = { + "filters": {"type": None}, + "groupByProperty": None, + "issueView": "list", + "orderBy": "-created_at", + "properties": { + "assignee": True, + "due_date": True, + "key": True, + "labels": True, + "priority": True, + "state": True, + "sub_issue_count": True, + "attachment_count": True, + "link": True, + "estimate": True, + "created_on": True, + "updated_on": True, + }, + "showEmptyGroups": True, + } + else: + current_view_props = obj.view_props + obj.view_props = { + "filters": {"type": None}, + "groupByProperty": None, + "issueView": "list", + "orderBy": "-created_at", + "showEmptyGroups": True, + "properties": current_view_props, + } + + updated_workspace_member.append(obj) + + Model.objects.bulk_update(updated_workspace_member, ["view_props"], batch_size=100) + + +def update_project_member_sort_order(apps, schema_editor): + Model = apps.get_model("db", "ProjectMember") + + updated_project_members = [] + + for obj in Model.objects.all(): + obj.sort_order = random.randint(1, 65536) + updated_project_members.append(obj) + + Model.objects.bulk_update(updated_project_members, ["sort_order"], batch_size=100) + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0038_auto_20230720_1505"), + ] + + operations = [ + migrations.RunPython(rename_field), + migrations.RunPython(update_workspace_member_props), + migrations.AlterField( + model_name='workspacemember', + name='view_props', + field=models.JSONField(default=plane.db.models.workspace.get_default_props), + ), + migrations.AddField( + model_name='workspacemember', + name='default_props', + field=models.JSONField(default=plane.db.models.workspace.get_default_props), + ), + migrations.AddField( + model_name='projectmember', + name='sort_order', + field=models.FloatField(default=65535), + ), + migrations.RunPython(update_project_member_sort_order), + ] diff --git a/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py b/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py new file mode 100644 index 000000000..5662ef666 --- /dev/null +++ b/apiserver/plane/db/migrations/0040_projectmember_preferences_user_cover_image_and_more.py @@ -0,0 +1,81 @@ +# Generated by Django 4.2.3 on 2023-08-01 06:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.project +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0039_auto_20230723_2203'), + ] + + operations = [ + migrations.AddField( + model_name='projectmember', + name='preferences', + field=models.JSONField(default=plane.db.models.project.get_default_preferences), + ), + migrations.AddField( + model_name='user', + name='cover_image', + field=models.URLField(blank=True, max_length=800, null=True), + ), + migrations.CreateModel( + name='IssueReaction', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('reaction', models.CharField(max_length=20)), + ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_reactions', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_reactions', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ], + options={ + 'verbose_name': 'Issue Reaction', + 'verbose_name_plural': 'Issue Reactions', + 'db_table': 'issue_reactions', + 'ordering': ('-created_at',), + 'unique_together': {('issue', 'actor', 'reaction')}, + }, + ), + migrations.CreateModel( + name='CommentReaction', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('reaction', models.CharField(max_length=20)), + ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_reactions', to=settings.AUTH_USER_MODEL)), + ('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comment_reactions', to='db.issuecomment')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')), + ], + options={ + 'verbose_name': 'Comment Reaction', + 'verbose_name_plural': 'Comment Reactions', + 'db_table': 'comment_reactions', + 'ordering': ('-created_at',), + 'unique_together': {('comment', 'actor', 'reaction')}, + }, + ), + migrations.AlterField( + model_name='project', + name='identifier', + field=models.CharField(max_length=12, verbose_name='Project Identifier'), + ), + migrations.AlterField( + model_name='projectidentifier', + name='name', + field=models.CharField(max_length=12), + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 1c075478d..959dea5f7 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -34,6 +34,8 @@ from .issue import ( IssueSequence, IssueAttachment, IssueSubscriber, + IssueReaction, + CommentReaction, ) from .asset import FileAsset diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 3e9da02d5..2a4462942 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -209,7 +209,7 @@ class IssueAssignee(ProjectBaseModel): class IssueLink(ProjectBaseModel): - title = models.CharField(max_length=255, null=True) + title = models.CharField(max_length=255, null=True, blank=True) url = models.URLField() issue = models.ForeignKey( "db.Issue", on_delete=models.CASCADE, related_name="issue_link" @@ -424,6 +424,49 @@ class IssueSubscriber(ProjectBaseModel): return f"{self.issue.name} {self.subscriber.email}" +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") + reaction = models.CharField(max_length=20) + + class Meta: + unique_together = ["issue", "actor", "reaction"] + verbose_name = "Issue Reaction" + verbose_name_plural = "Issue Reactions" + db_table = "issue_reactions" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.actor.email}" + + +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") + reaction = models.CharField(max_length=20) + + class Meta: + unique_together = ["comment", "actor", "reaction"] + verbose_name = "Comment Reaction" + verbose_name_plural = "Comment Reactions" + db_table = "comment_reactions" + 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) def create_issue_sequence(sender, instance, created, **kwargs): diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index b28cbc69e..2cbd70369 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -31,6 +31,13 @@ def get_default_props(): "showEmptyGroups": True, } +def get_default_preferences(): + return { + "pages": { + "block_display": True + } + } + class Project(BaseModel): NETWORK_CHOICES = ((0, "Secret"), (2, "Public")) @@ -47,7 +54,7 @@ class Project(BaseModel): "db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project" ) identifier = models.CharField( - max_length=5, + max_length=12, verbose_name="Project Identifier", ) default_assignee = models.ForeignKey( @@ -147,6 +154,21 @@ class ProjectMember(ProjectBaseModel): role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) view_props = models.JSONField(default=get_default_props) default_props = models.JSONField(default=get_default_props) + 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( + workspace_id=self.project.workspace_id, member=self.member + ).aggregate(smallest=models.Min("sort_order"))["smallest"] + + # Project ordering + if smallest_sort_order is not None: + self.sort_order = smallest_sort_order - 10000 + + super(ProjectMember, self).save(*args, **kwargs) class Meta: unique_together = ["project", "member"] @@ -168,7 +190,7 @@ class ProjectIdentifier(AuditModel): project = models.OneToOneField( Project, on_delete=models.CASCADE, related_name="project_identifier" ) - name = models.CharField(max_length=10) + name = models.CharField(max_length=12) class Meta: unique_together = ["name", "workspace"] diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 36b3a1f6b..0b643271e 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -38,6 +38,7 @@ class User(AbstractBaseUser, PermissionsMixin): first_name = models.CharField(max_length=255, blank=True) last_name = models.CharField(max_length=255, blank=True) avatar = models.CharField(max_length=255, blank=True) + cover_image = models.URLField(blank=True, null=True, max_length=800) # tracking metrics date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Created At") diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 9b9fbb68c..09db42002 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -14,6 +14,30 @@ ROLE_CHOICES = ( ) +def get_default_props(): + return { + "filters": {"type": None}, + "groupByProperty": None, + "issueView": "list", + "orderBy": "-created_at", + "properties": { + "assignee": True, + "due_date": True, + "key": True, + "labels": True, + "priority": True, + "state": True, + "sub_issue_count": True, + "attachment_count": True, + "link": True, + "estimate": True, + "created_on": True, + "updated_on": True, + }, + "showEmptyGroups": True, + } + + class Workspace(BaseModel): name = models.CharField(max_length=80, verbose_name="Workspace Name") logo = models.URLField(verbose_name="Logo", blank=True, null=True) @@ -47,7 +71,8 @@ class WorkspaceMember(BaseModel): ) role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=10) company_role = models.TextField(null=True, blank=True) - view_props = models.JSONField(null=True, blank=True) + view_props = models.JSONField(default=get_default_props) + default_props = models.JSONField(default=get_default_props) class Meta: unique_together = ["workspace", "member"] diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 6a9e8b8e8..a7a946e60 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -12,6 +12,18 @@ def filter_state(params, filter, method): return filter +def filter_state_group(params, filter, method): + if method == "GET": + state_group = params.get("state_group").split(",") + if len(state_group) and "" not in state_group: + filter["state__group__in"] = state_group + else: + if params.get("state_group", None) and len(params.get("state_group")): + filter["state__group__in"] = params.get("state_group") + return filter + + + def filter_estimate_point(params, filter, method): if method == "GET": estimate_points = params.get("estimate_point").split(",") @@ -212,6 +224,7 @@ def filter_issue_state_type(params, filter, method): return filter + def filter_project(params, filter, method): if method == "GET": projects = params.get("project").split(",") @@ -268,11 +281,24 @@ def filter_sub_issue_toggle(params, filter, method): return filter +def filter_subscribed_issues(params, filter, method): + if method == "GET": + subscribers = params.get("subscriber").split(",") + if len(subscribers) and "" not in subscribers: + filter["issue_subscribers__subscriber_id__in"] = subscribers + else: + if params.get("subscriber", None) and len(params.get("subscriber")): + filter["issue_subscribers__subscriber_id__in"] = params.get("subscriber") + return filter + + def issue_filters(query_params, method): filter = dict() + print(query_params) ISSUE_FILTER = { "state": filter_state, + "state_group": filter_state_group, "estimate_point": filter_estimate_point, "priority": filter_priority, "parent": filter_parent, @@ -291,6 +317,7 @@ def issue_filters(query_params, method): "module": filter_module, "inbox_status": filter_inbox_status, "sub_issue": filter_sub_issue_toggle, + "subscriber": filter_subscribed_issues, } for key, value in ISSUE_FILTER.items(): diff --git a/apiserver/templates/emails/auth/magic_signin.html b/apiserver/templates/emails/auth/magic_signin.html index bc7ed12aa..63fbe5e32 100644 --- a/apiserver/templates/emails/auth/magic_signin.html +++ b/apiserver/templates/emails/auth/magic_signin.html @@ -89,8 +89,8 @@ - +

Open Plane

- + @@ -364,4 +364,4 @@ - \ No newline at end of file + diff --git a/apiserver/templates/emails/invitations/project_invitation.html b/apiserver/templates/emails/invitations/project_invitation.html index beca6a735..ea2f1cdcf 100644 --- a/apiserver/templates/emails/invitations/project_invitation.html +++ b/apiserver/templates/emails/invitations/project_invitation.html @@ -90,8 +90,8 @@ - +

Accept the invite

- + @@ -346,4 +346,4 @@ - \ No newline at end of file + diff --git a/apiserver/templates/emails/invitations/workspace_invitation.html b/apiserver/templates/emails/invitations/workspace_invitation.html index 4d7f90a16..2384aa18d 100644 --- a/apiserver/templates/emails/invitations/workspace_invitation.html +++ b/apiserver/templates/emails/invitations/workspace_invitation.html @@ -90,8 +90,8 @@ - +

Accept the invite

- + @@ -346,4 +346,4 @@ - \ No newline at end of file + diff --git a/apps/app/components/analytics/custom-analytics/sidebar.tsx b/apps/app/components/analytics/custom-analytics/sidebar.tsx index fbef3913c..d1a29da41 100644 --- a/apps/app/components/analytics/custom-analytics/sidebar.tsx +++ b/apps/app/components/analytics/custom-analytics/sidebar.tsx @@ -227,12 +227,7 @@ export const AnalyticsSidebar: React.FC = ({ ) : project.icon_prop ? (
- - {project.icon_prop.name} - + {renderEmoji(project.icon_prop)}
) : ( @@ -342,12 +337,7 @@ export const AnalyticsSidebar: React.FC = ({ ) : projectDetails?.icon_prop ? (
- - {projectDetails.icon_prop.name} - + {renderEmoji(projectDetails.icon_prop)}
) : ( @@ -360,11 +350,8 @@ export const AnalyticsSidebar: React.FC = ({
Network
- { - NETWORK_CHOICES[ - `${projectDetails?.network}` as keyof typeof NETWORK_CHOICES - ] - } + {NETWORK_CHOICES.find((n) => n.key === projectDetails?.network)?.label ?? + ""}
diff --git a/apps/app/components/automation/auto-archive-automation.tsx b/apps/app/components/automation/auto-archive-automation.tsx index 8772371c4..07ac86460 100644 --- a/apps/app/components/automation/auto-archive-automation.tsx +++ b/apps/app/components/automation/auto-archive-automation.tsx @@ -28,7 +28,7 @@ export const AutoArchiveAutomation: React.FC = ({ projectDetails, handleC handleClose={() => setmonthModal(false)} handleChange={handleChange} /> -
+

Auto-archive closed issues

diff --git a/apps/app/components/automation/auto-close-automation.tsx b/apps/app/components/automation/auto-close-automation.tsx index 03df06235..3e71b8329 100644 --- a/apps/app/components/automation/auto-close-automation.tsx +++ b/apps/app/components/automation/auto-close-automation.tsx @@ -37,8 +37,7 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha ? () => stateService.getStates(workspaceSlug as string, projectId as string) : null ); - - const states = getStatesList(stateGroups ?? {}); + const states = getStatesList(stateGroups); const options = states ?.filter((state) => state.group === "cancelled") @@ -53,14 +52,14 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha ), })); - const multipleOptions = options.length > 1; + const multipleOptions = (options ?? []).length > 1; const defaultState = stateGroups && stateGroups.cancelled ? stateGroups.cancelled[0].id : null; const selectedOption = states?.find( (s) => s.id === projectDetails?.default_state ?? defaultState ); - const currentDefaultState = states.find((s) => s.id === defaultState); + const currentDefaultState = states?.find((s) => s.id === defaultState); const initialValues: Partial = { close_in: 1, @@ -77,7 +76,7 @@ export const AutoCloseAutomation: React.FC = ({ projectDetails, handleCha handleChange={handleChange} /> -
+

Auto-close inactive issues

diff --git a/apps/app/components/breadcrumbs/index.tsx b/apps/app/components/breadcrumbs/index.tsx index 8a67c92b9..0e5cfb9c4 100644 --- a/apps/app/components/breadcrumbs/index.tsx +++ b/apps/app/components/breadcrumbs/index.tsx @@ -2,7 +2,6 @@ import * as React from "react"; import { useRouter } from "next/router"; import Link from "next/link"; // icons -import { ArrowLeftIcon } from "@heroicons/react/24/outline"; import { Icon } from "components/ui"; type BreadcrumbsProps = { @@ -14,7 +13,7 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => { return ( <> -
+
+ setIsWorkspaceLevel((prevData) => !prevData)} + /> +
+ + )} +
+
+
+ + + {searchTerm !== "" && ( +
+ Search results for{" "} + + {'"'} + {searchTerm} + {'"'} + {" "} + in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: +
+ )} + + {!isLoading && + resultsCount === 0 && + searchTerm !== "" && + debouncedSearchTerm !== "" && ( +
No results found.
+ )} + + {(isLoading || isSearching) && ( + + + + + + + + + )} + + {debouncedSearchTerm !== "" && + Object.keys(results.results).map((key) => { + const section = (results.results as any)[key]; + const currentSection = commandGroups[key]; + + if (section.length > 0) { + return ( + + {section.map((item: any) => ( + { + router.push(currentSection.path(item)); + setIsPaletteOpen(false); + }} + value={`${key}-${item?.name}`} + className="focus:outline-none" + > +
+ +

+ {currentSection.itemName(item)} +

+
+
+ ))} +
+ ); + } + })} + + {!page && ( + <> + {issueId && ( + + { + setPlaceholder("Change state..."); + setSearchTerm(""); + setPages([...pages, "change-issue-state"]); + }} + className="focus:outline-none" + > +
+ + Change state... +
+
+ { + setPlaceholder("Change priority..."); + setSearchTerm(""); + setPages([...pages, "change-issue-priority"]); + }} + className="focus:outline-none" + > +
+ + Change priority... +
+
+ { + setPlaceholder("Assign to..."); + setSearchTerm(""); + setPages([...pages, "change-issue-assignee"]); + }} + className="focus:outline-none" + > +
+ + Assign to... +
+
+ { + handleIssueAssignees(user.id); + setSearchTerm(""); + }} + className="focus:outline-none" + > +
+ {issueDetails?.assignees.includes(user.id) ? ( + <> + + Un-assign from me + + ) : ( + <> + + Assign to me + + )} +
+
+ +
+ + Delete issue +
+
+ { + setIsPaletteOpen(false); + copyIssueUrlToClipboard(); + }} + className="focus:outline-none" + > +
+ + Copy issue URL +
+
+
+ )} + + { + const e = new KeyboardEvent("keydown", { + key: "c", + }); + document.dispatchEvent(e); + }} + className="focus:bg-custom-background-80" + > +
+ + Create new issue +
+ C +
+
+ + {workspaceSlug && ( + + { + const e = new KeyboardEvent("keydown", { + key: "p", + }); + document.dispatchEvent(e); + }} + className="focus:outline-none" + > +
+ + Create new project +
+ P +
+
+ )} + + {projectId && ( + <> + + { + const e = new KeyboardEvent("keydown", { + key: "q", + }); + document.dispatchEvent(e); + }} + className="focus:outline-none" + > +
+ + Create new cycle +
+ Q +
+
+ + { + const e = new KeyboardEvent("keydown", { + key: "m", + }); + document.dispatchEvent(e); + }} + className="focus:outline-none" + > +
+ + Create new module +
+ M +
+
+ + { + const e = new KeyboardEvent("keydown", { + key: "v", + }); + document.dispatchEvent(e); + }} + className="focus:outline-none" + > +
+ + Create new view +
+ V +
+
+ + { + const e = new KeyboardEvent("keydown", { + key: "d", + }); + document.dispatchEvent(e); + }} + className="focus:outline-none" + > +
+ + Create new page +
+ D +
+
+ {projectDetails && projectDetails.inbox_view && ( + + + redirect( + `/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}` + ) + } + className="focus:outline-none" + > +
+ + Open inbox +
+
+
+ )} + + )} + + + { + setPlaceholder("Search workspace settings..."); + setSearchTerm(""); + setPages([...pages, "settings"]); + }} + className="focus:outline-none" + > +
+ + Search settings... +
+
+
+ + +
+ + Create new workspace +
+
+ { + setPlaceholder("Change interface theme..."); + setSearchTerm(""); + setPages([...pages, "change-interface-theme"]); + }} + className="focus:outline-none" + > +
+ + Change interface theme... +
+
+
+ + { + setIsPaletteOpen(false); + const e = new KeyboardEvent("keydown", { + key: "h", + }); + document.dispatchEvent(e); + }} + className="focus:outline-none" + > +
+ + Open keyboard shortcuts +
+
+ { + setIsPaletteOpen(false); + window.open("https://docs.plane.so/", "_blank"); + }} + className="focus:outline-none" + > +
+ + Open Plane documentation +
+
+ { + setIsPaletteOpen(false); + window.open("https://discord.com/invite/A92xrEGCge", "_blank"); + }} + className="focus:outline-none" + > +
+ + Join our Discord +
+
+ { + setIsPaletteOpen(false); + window.open( + "https://github.com/makeplane/plane/issues/new/choose", + "_blank" + ); + }} + className="focus:outline-none" + > +
+ + Report a bug +
+
+ { + setIsPaletteOpen(false); + (window as any).$crisp.push(["do", "chat:open"]); + }} + className="focus:outline-none" + > +
+ + Chat with us +
+
+
+ + )} + + {page === "settings" && workspaceSlug && ( + <> + redirect(`/${workspaceSlug}/settings`)} + className="focus:outline-none" + > +
+ + General +
+
+ redirect(`/${workspaceSlug}/settings/members`)} + className="focus:outline-none" + > +
+ + Members +
+
+ redirect(`/${workspaceSlug}/settings/billing`)} + className="focus:outline-none" + > +
+ + Billing and Plans +
+
+ redirect(`/${workspaceSlug}/settings/integrations`)} + className="focus:outline-none" + > +
+ + Integrations +
+
+ redirect(`/${workspaceSlug}/settings/import-export`)} + className="focus:outline-none" + > +
+ + Import/Export +
+
+ + )} + {page === "change-issue-state" && issueDetails && ( + + )} + {page === "change-issue-priority" && issueDetails && ( + + )} + {page === "change-issue-assignee" && issueDetails && ( + + )} + {page === "change-interface-theme" && ( + + )} +
+ + + +
+ + + ); +}; diff --git a/apps/app/components/command-palette/command-pallette.tsx b/apps/app/components/command-palette/command-pallette.tsx index 5ce741ae8..0b4c9577b 100644 --- a/apps/app/components/command-palette/command-pallette.tsx +++ b/apps/app/components/command-palette/command-pallette.tsx @@ -1,53 +1,15 @@ -import { useRouter } from "next/router"; import React, { useCallback, useEffect, useState } from "react"; -import useSWR, { mutate } from "swr"; -// icons -import { - ArrowRightIcon, - ChartBarIcon, - ChatBubbleOvalLeftEllipsisIcon, - DocumentTextIcon, - FolderPlusIcon, - InboxIcon, - LinkIcon, - MagnifyingGlassIcon, - RocketLaunchIcon, - Squares2X2Icon, - TrashIcon, - UserMinusIcon, - UserPlusIcon, - UsersIcon, -} from "@heroicons/react/24/outline"; -import { - AssignmentClipboardIcon, - ContrastIcon, - DiscordIcon, - DocumentIcon, - GithubIcon, - LayerDiagonalIcon, - PeopleGroupIcon, - SettingIcon, - ViewListIcon, -} from "components/icons"; -// headless ui -import { Dialog, Transition } from "@headlessui/react"; -// cmdk -import { Command } from "cmdk"; +import { useRouter } from "next/router"; + +import useSWR from "swr"; + // hooks -import useProjectDetails from "hooks/use-project-details"; import useTheme from "hooks/use-theme"; import useToast from "hooks/use-toast"; import useUser from "hooks/use-user"; -import useDebounce from "hooks/use-debounce"; // components -import { - ShortcutsModal, - ChangeIssueState, - ChangeIssuePriority, - ChangeIssueAssignee, - ChangeInterfaceTheme, -} from "components/command-palette"; +import { CommandK, ShortcutsModal } from "components/command-palette"; import { BulkDeleteIssuesModal } from "components/core"; import { CreateUpdateCycleModal } from "components/cycles"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; @@ -55,22 +17,13 @@ import { CreateUpdateModuleModal } from "components/modules"; import { CreateProjectModal } from "components/project"; import { CreateUpdateViewModal } from "components/views"; import { CreateUpdatePageModal } from "components/pages"; - -import { Spinner } from "components/ui"; // helpers -import { - capitalizeFirstLetter, - copyTextToClipboard, - replaceUnderscoreIfSnakeCase, -} from "helpers/string.helper"; +import { copyTextToClipboard } from "helpers/string.helper"; // services import issuesService from "services/issues.service"; -import workspaceService from "services/workspace.service"; import inboxService from "services/inbox.service"; -// types -import { IIssue, IWorkspaceSearchResults } from "types"; // fetch keys -import { INBOX_LIST, ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; +import { INBOX_LIST, ISSUE_DETAILS } from "constants/fetch-keys"; export const CommandPalette: React.FC = () => { const [isPaletteOpen, setIsPaletteOpen] = useState(false); @@ -84,36 +37,15 @@ export const CommandPalette: React.FC = () => { const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [results, setResults] = useState({ - results: { - workspace: [], - project: [], - issue: [], - cycle: [], - module: [], - issue_view: [], - page: [], - }, - }); - const [resultsCount, setResultsCount] = useState(0); - const [isLoading, setIsLoading] = useState(false); - const [isSearching, setIsSearching] = useState(false); - const debouncedSearchTerm = useDebounce(searchTerm, 500); - const [placeholder, setPlaceholder] = React.useState("Type a command or search..."); - const [pages, setPages] = React.useState([]); - const page = pages[pages.length - 1]; - const router = useRouter(); const { workspaceSlug, projectId, issueId, inboxId } = router.query; const { user } = useUser(); - const { projectDetails } = useProjectDetails(); const { setToastAlert } = useToast(); const { toggleCollapsed } = useTheme(); - const { data: issueDetails } = useSWR( + const { data: issueDetails } = useSWR( workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null, workspaceSlug && projectId && issueId ? () => @@ -121,59 +53,6 @@ export const CommandPalette: React.FC = () => { : null ); - const { data: inboxList } = useSWR( - workspaceSlug && projectId ? INBOX_LIST(projectId as string) : null, - workspaceSlug && projectId - ? () => inboxService.getInboxes(workspaceSlug as string, projectId as string) - : null - ); - - const updateIssue = useCallback( - async (formData: Partial) => { - if (!workspaceSlug || !projectId || !issueId) return; - - mutate( - ISSUE_DETAILS(issueId as string), - - (prevData) => { - if (!prevData) return prevData; - - return { - ...prevData, - ...formData, - }; - }, - false - ); - - const payload = { ...formData }; - await issuesService - .patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user) - .then(() => { - mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); - mutate(ISSUE_DETAILS(issueId as string)); - }) - .catch((e) => { - console.error(e); - }); - }, - [workspaceSlug, issueId, projectId, user] - ); - - const handleIssueAssignees = (assignee: string) => { - if (!issueDetails) return; - - setIsPaletteOpen(false); - const updatedAssignees = issueDetails.assignees ?? []; - - if (updatedAssignees.includes(assignee)) { - updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1); - } else { - updatedAssignees.push(assignee); - } - updateIssue({ assignees_list: updatedAssignees }); - }; - const copyIssueUrlToClipboard = useCallback(() => { if (!router.query.issueId) return; @@ -246,98 +125,17 @@ export const CommandPalette: React.FC = () => { useEffect(() => { document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); - useEffect( - () => { - if (!workspaceSlug || !projectId) return; - - setIsLoading(true); - // this is done prevent subsequent api request - // or searchTerm has not been updated within last 500ms. - if (debouncedSearchTerm) { - setIsSearching(true); - workspaceService - .searchWorkspace(workspaceSlug as string, projectId as string, debouncedSearchTerm) - .then((results) => { - setResults(results); - const count = Object.keys(results.results).reduce( - (accumulator, key) => (results.results as any)[key].length + accumulator, - 0 - ); - setResultsCount(count); - }) - .finally(() => { - setIsLoading(false); - setIsSearching(false); - }); - } else { - setResults({ - results: { - workspace: [], - project: [], - issue: [], - cycle: [], - module: [], - issue_view: [], - page: [], - }, - }); - setIsLoading(false); - setIsSearching(false); - } - }, - [debouncedSearchTerm, workspaceSlug, projectId] // Only call effect if debounced search term changes - ); - if (!user) return null; - const createNewWorkspace = () => { - setIsPaletteOpen(false); - router.push("/create-workspace"); - }; - - const createNewProject = () => { - setIsPaletteOpen(false); - setIsProjectModalOpen(true); - }; - - const createNewIssue = () => { - setIsPaletteOpen(false); - setIsIssueModalOpen(true); - }; - - const createNewCycle = () => { - setIsPaletteOpen(false); - setIsCreateCycleModalOpen(true); - }; - - const createNewView = () => { - setIsPaletteOpen(false); - setIsCreateViewModalOpen(true); - }; - - const createNewPage = () => { - setIsPaletteOpen(false); - setIsCreateUpdatePageModalOpen(true); - }; - - const createNewModule = () => { - setIsPaletteOpen(false); - setIsCreateModuleModalOpen(true); - }; - const deleteIssue = () => { setIsPaletteOpen(false); setDeleteIssueModal(true); }; - const redirect = (path: string) => { - setIsPaletteOpen(false); - router.push(path); - }; - return ( <> @@ -390,538 +188,11 @@ export const CommandPalette: React.FC = () => { setIsOpen={setIsBulkDeleteIssuesModalOpen} user={user} /> - { - setSearchTerm(""); - }} - as={React.Fragment} - > - setIsPaletteOpen(false)}> - -
- - -
- - - { - if (value.toLowerCase().includes(search.toLowerCase())) return 1; - return 0; - }} - onKeyDown={(e) => { - // when search is empty and page is undefined - // when user tries to close the modal with esc - if (e.key === "Escape" && !page && !searchTerm) { - setIsPaletteOpen(false); - } - // Escape goes to previous page - // Backspace goes to previous page when search is empty - if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) { - e.preventDefault(); - setPages((pages) => pages.slice(0, -1)); - setPlaceholder("Type a command or search..."); - } - }} - > - {issueId && issueDetails && ( -
-

- {issueDetails.project_detail?.identifier}-{issueDetails.sequence_id}{" "} - {issueDetails?.name} -

-
- )} -
-
- - {!isLoading && - resultsCount === 0 && - searchTerm !== "" && - debouncedSearchTerm !== "" && ( -
- No results found. -
- )} - - {(isLoading || isSearching) && ( - -
- -
-
- )} - - {debouncedSearchTerm !== "" && ( - <> - {Object.keys(results.results).map((key) => { - const section = (results.results as any)[key]; - if (section.length > 0) { - return ( - - {section.map((item: any) => { - let path = ""; - let value = item.name; - let Icon: any = ArrowRightIcon; - - if (key === "workspace") { - path = `/${item.slug}`; - Icon = FolderPlusIcon; - } else if (key == "project") { - path = `/${item.workspace__slug}/projects/${item.id}/issues`; - Icon = AssignmentClipboardIcon; - } else if (key === "issue") { - path = `/${item.workspace__slug}/projects/${item.project_id}/issues/${item.id}`; - // user can search id-num idnum or issue name - value = `${item.project__identifier}-${item.sequence_id} ${item.project__identifier}${item.sequence_id} ${item.name}`; - Icon = LayerDiagonalIcon; - } else if (key === "issue_view") { - path = `/${item.workspace__slug}/projects/${item.project_id}/views/${item.id}`; - Icon = ViewListIcon; - } else if (key === "module") { - path = `/${item.workspace__slug}/projects/${item.project_id}/modules/${item.id}`; - Icon = PeopleGroupIcon; - } else if (key === "page") { - path = `/${item.workspace__slug}/projects/${item.project_id}/pages/${item.id}`; - Icon = DocumentTextIcon; - } else if (key === "cycle") { - path = `/${item.workspace__slug}/projects/${item.project_id}/cycles/${item.id}`; - Icon = ContrastIcon; - } - - return ( - { - router.push(path); - setIsPaletteOpen(false); - }} - value={value} - className="focus:outline-none" - > -
- -

{item.name}

-
-
- ); - })} -
- ); - } - })} - - )} - - {!page && ( - <> - {issueId && ( - <> - { - setPlaceholder("Change state..."); - setSearchTerm(""); - setPages([...pages, "change-issue-state"]); - }} - className="focus:outline-none" - > -
- - Change state... -
-
- { - setPlaceholder("Change priority..."); - setSearchTerm(""); - setPages([...pages, "change-issue-priority"]); - }} - className="focus:outline-none" - > -
- - Change priority... -
-
- { - setPlaceholder("Assign to..."); - setSearchTerm(""); - setPages([...pages, "change-issue-assignee"]); - }} - className="focus:outline-none" - > -
- - Assign to... -
-
- { - handleIssueAssignees(user.id); - setSearchTerm(""); - }} - className="focus:outline-none" - > -
- {issueDetails?.assignees.includes(user.id) ? ( - <> - - Un-assign from me - - ) : ( - <> - - Assign to me - - )} -
-
- - -
- - Delete issue -
-
- { - setIsPaletteOpen(false); - copyIssueUrlToClipboard(); - }} - className="focus:outline-none" - > -
- - Copy issue URL to clipboard -
-
- - )} - - -
- - Create new issue -
- C -
-
- - {workspaceSlug && ( - - -
- - Create new project -
- P -
-
- )} - - {projectId && ( - <> - - -
- - Create new cycle -
- Q -
-
- - - -
- - Create new module -
- M -
-
- - - -
- - Create new view -
- V -
-
- - - -
- - Create new page -
- D -
-
- - {projectDetails && projectDetails.inbox_view && ( - - - redirect( - `/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}` - ) - } - className="focus:outline-none" - > -
- - Open inbox -
-
-
- )} - - )} - - - { - setPlaceholder("Search workspace settings..."); - setSearchTerm(""); - setPages([...pages, "settings"]); - }} - className="focus:outline-none" - > -
- - Search settings... -
-
-
- - -
- - Create new workspace -
-
- { - setPlaceholder("Change interface theme..."); - setSearchTerm(""); - setPages([...pages, "change-interface-theme"]); - }} - className="focus:outline-none" - > -
- - Change interface theme... -
-
-
- - { - setIsPaletteOpen(false); - const e = new KeyboardEvent("keydown", { - key: "h", - }); - document.dispatchEvent(e); - }} - className="focus:outline-none" - > -
- - Open keyboard shortcuts -
-
- { - setIsPaletteOpen(false); - window.open("https://docs.plane.so/", "_blank"); - }} - className="focus:outline-none" - > -
- - Open Plane documentation -
-
- { - setIsPaletteOpen(false); - window.open("https://discord.com/invite/A92xrEGCge", "_blank"); - }} - className="focus:outline-none" - > -
- - Join our Discord -
-
- { - setIsPaletteOpen(false); - window.open( - "https://github.com/makeplane/plane/issues/new/choose", - "_blank" - ); - }} - className="focus:outline-none" - > -
- - Report a bug -
-
- { - setIsPaletteOpen(false); - (window as any).$crisp.push(["do", "chat:open"]); - }} - className="focus:outline-none" - > -
- - Chat with us -
-
-
- - )} - - {page === "settings" && workspaceSlug && ( - <> - redirect(`/${workspaceSlug}/settings`)} - className="focus:outline-none" - > -
- - General -
-
- redirect(`/${workspaceSlug}/settings/members`)} - className="focus:outline-none" - > -
- - Members -
-
- redirect(`/${workspaceSlug}/settings/billing`)} - className="focus:outline-none" - > -
- - Billing and Plans -
-
- redirect(`/${workspaceSlug}/settings/integrations`)} - className="focus:outline-none" - > -
- - Integrations -
-
- redirect(`/${workspaceSlug}/settings/import-export`)} - className="focus:outline-none" - > -
- - Import/Export -
-
- - )} - {page === "change-issue-state" && issueDetails && ( - <> - - - )} - {page === "change-issue-priority" && issueDetails && ( - - )} - {page === "change-issue-assignee" && issueDetails && ( - - )} - {page === "change-interface-theme" && ( - - )} -
-
-
-
-
-
-
+ ); }; diff --git a/apps/app/components/command-palette/helpers.tsx b/apps/app/components/command-palette/helpers.tsx new file mode 100644 index 000000000..0b232b7dc --- /dev/null +++ b/apps/app/components/command-palette/helpers.tsx @@ -0,0 +1,95 @@ +// types +import { + IWorkspaceDefaultSearchResult, + IWorkspaceIssueSearchResult, + IWorkspaceProjectSearchResult, + IWorkspaceSearchResult, +} from "types"; + +export const commandGroups: { + [key: string]: { + icon: string; + itemName: (item: any) => React.ReactNode; + path: (item: any) => string; + title: string; + }; +} = { + cycle: { + icon: "contrast", + itemName: (cycle: IWorkspaceDefaultSearchResult) => ( +
+ {cycle.project__identifier} + {"- "} + {cycle.name} +
+ ), + path: (cycle: IWorkspaceDefaultSearchResult) => + `/${cycle?.workspace__slug}/projects/${cycle?.project_id}/cycles/${cycle?.id}`, + title: "Cycles", + }, + issue: { + icon: "stack", + itemName: (issue: IWorkspaceIssueSearchResult) => ( +
+ {issue.project__identifier} + {"- "} + {issue.name} +
+ ), + path: (issue: IWorkspaceIssueSearchResult) => + `/${issue?.workspace__slug}/projects/${issue?.project_id}/issues/${issue?.id}`, + title: "Issues", + }, + issue_view: { + icon: "photo_filter", + itemName: (view: IWorkspaceDefaultSearchResult) => ( +
+ {view.project__identifier} + {"- "} + {view.name} +
+ ), + path: (view: IWorkspaceDefaultSearchResult) => + `/${view?.workspace__slug}/projects/${view?.project_id}/views/${view?.id}`, + title: "Views", + }, + module: { + icon: "dataset", + itemName: (module: IWorkspaceDefaultSearchResult) => ( +
+ {module.project__identifier} + {"- "} + {module.name} +
+ ), + path: (module: IWorkspaceDefaultSearchResult) => + `/${module?.workspace__slug}/projects/${module?.project_id}/modules/${module?.id}`, + title: "Modules", + }, + page: { + icon: "article", + itemName: (page: IWorkspaceDefaultSearchResult) => ( +
+ {page.project__identifier} + {"- "} + {page.name} +
+ ), + path: (page: IWorkspaceDefaultSearchResult) => + `/${page?.workspace__slug}/projects/${page?.project_id}/pages/${page?.id}`, + title: "Pages", + }, + project: { + icon: "work", + itemName: (project: IWorkspaceProjectSearchResult) => project?.name, + path: (project: IWorkspaceProjectSearchResult) => + `/${project?.workspace__slug}/projects/${project?.id}/issues/`, + title: "Projects", + }, + workspace: { + icon: "grid_view", + itemName: (workspace: IWorkspaceSearchResult) => workspace?.name, + path: (workspace: IWorkspaceSearchResult) => `/${workspace?.slug}/`, + title: "Workspaces", + }, +}; diff --git a/apps/app/components/command-palette/index.ts b/apps/app/components/command-palette/index.ts index 858aba401..6c137d6df 100644 --- a/apps/app/components/command-palette/index.ts +++ b/apps/app/components/command-palette/index.ts @@ -1,6 +1,6 @@ -export * from "./command-pallette"; -export * from "./shortcuts-modal"; -export * from "./change-issue-state"; -export * from "./change-issue-priority"; -export * from "./change-issue-assignee"; +export * from "./issue"; export * from "./change-interface-theme"; +export * from "./command-k"; +export * from "./command-pallette"; +export * from "./helpers"; +export * from "./shortcuts-modal"; diff --git a/apps/app/components/command-palette/change-issue-assignee.tsx b/apps/app/components/command-palette/issue/change-issue-assignee.tsx similarity index 86% rename from apps/app/components/command-palette/change-issue-assignee.tsx rename to apps/app/components/command-palette/issue/change-issue-assignee.tsx index 1021623db..e272839bd 100644 --- a/apps/app/components/command-palette/change-issue-assignee.tsx +++ b/apps/app/components/command-palette/issue/change-issue-assignee.tsx @@ -1,19 +1,23 @@ -import { useRouter } from "next/router"; import React, { Dispatch, SetStateAction, useCallback } from "react"; -import useSWR, { mutate } from "swr"; + +import { useRouter } from "next/router"; + +import { mutate } from "swr"; // cmdk import { Command } from "cmdk"; // services import issuesService from "services/issues.service"; -// types -import { ICurrentUserResponse, IIssue } from "types"; +// hooks +import useProjectMembers from "hooks/use-project-members"; // constants -import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, PROJECT_MEMBERS } from "constants/fetch-keys"; +import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; +// ui +import { Avatar } from "components/ui"; // icons import { CheckIcon } from "components/icons"; -import projectService from "services/project.service"; -import { Avatar } from "components/ui"; +// types +import { ICurrentUserResponse, IIssue } from "types"; type Props = { setIsPaletteOpen: Dispatch>; @@ -25,12 +29,7 @@ export const ChangeIssueAssignee: React.FC = ({ setIsPaletteOpen, issue, const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; - const { data: members } = useSWR( - projectId ? PROJECT_MEMBERS(projectId as string) : null, - workspaceSlug && projectId - ? () => projectService.projectMembers(workspaceSlug as string, projectId as string) - : null - ); + const { members } = useProjectMembers(workspaceSlug as string, projectId as string); const options = members?.map(({ member }) => ({ diff --git a/apps/app/components/command-palette/change-issue-priority.tsx b/apps/app/components/command-palette/issue/change-issue-priority.tsx similarity index 100% rename from apps/app/components/command-palette/change-issue-priority.tsx rename to apps/app/components/command-palette/issue/change-issue-priority.tsx diff --git a/apps/app/components/command-palette/change-issue-state.tsx b/apps/app/components/command-palette/issue/change-issue-state.tsx similarity index 98% rename from apps/app/components/command-palette/change-issue-state.tsx rename to apps/app/components/command-palette/issue/change-issue-state.tsx index 00c9745be..30e2cdb77 100644 --- a/apps/app/components/command-palette/change-issue-state.tsx +++ b/apps/app/components/command-palette/issue/change-issue-state.tsx @@ -34,7 +34,7 @@ export const ChangeIssueState: React.FC = ({ setIsPaletteOpen, issue, use ? () => stateService.getStates(workspaceSlug as string, projectId as string) : null ); - const states = getStatesList(stateGroups ?? {}); + const states = getStatesList(stateGroups); const submitChanges = useCallback( async (formData: Partial) => { diff --git a/apps/app/components/command-palette/issue/index.ts b/apps/app/components/command-palette/issue/index.ts new file mode 100644 index 000000000..7d0bbd05d --- /dev/null +++ b/apps/app/components/command-palette/issue/index.ts @@ -0,0 +1,3 @@ +export * from "./change-issue-state"; +export * from "./change-issue-priority"; +export * from "./change-issue-assignee"; diff --git a/apps/app/components/core/activity.tsx b/apps/app/components/core/activity.tsx new file mode 100644 index 000000000..f7622baa6 --- /dev/null +++ b/apps/app/components/core/activity.tsx @@ -0,0 +1,497 @@ +import { useRouter } from "next/router"; + +// icons +import { Icon, Tooltip } from "components/ui"; +import { Squares2X2Icon } from "@heroicons/react/24/outline"; +import { BlockedIcon, BlockerIcon } from "components/icons"; +// helpers +import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; +import { capitalizeFirstLetter } from "helpers/string.helper"; +// types +import { IIssueActivity } from "types"; + +const IssueLink = ({ activity }: { activity: IIssueActivity }) => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + return ( + + + {activity.issue_detail + ? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}` + : "Issue"} + + + + ); +}; + +const activityDetails: { + [key: string]: { + message: (activity: IIssueActivity, showIssue: boolean) => React.ReactNode; + icon: React.ReactNode; + }; +} = { + assignees: { + message: (activity, showIssue) => { + if (activity.old_value === "") + return ( + <> + added a new assignee{" "} + {activity.new_value} + {showIssue && ( + <> + {" "} + to + + )} + . + + ); + else + return ( + <> + removed the assignee{" "} + {activity.old_value} + {showIssue && ( + <> + {" "} + from + + )} + . + + ); + }, + icon:
+
+ {fileRejections.length > 0 && ( +

+ {fileRejections[0].errors[0].code === "file-too-large" + ? "The image size cannot exceed 5 MB." + : "Please upload a file in a valid format."} +

+ )} + +

+ File formats supported- .jpeg, .jpg, .png, .webp, .svg +

+ +
+ { + setIsOpen(false); + setImage(null); + }} + > + Cancel + + + {isImageUploading ? "Uploading..." : "Upload & Save"} + +
+
diff --git a/apps/app/components/core/index.ts b/apps/app/components/core/index.ts index b91944abf..259655ae7 100644 --- a/apps/app/components/core/index.ts +++ b/apps/app/components/core/index.ts @@ -1,12 +1,9 @@ -export * from "./board-view"; -export * from "./calendar-view"; export * from "./filters"; -export * from "./gantt-chart-view"; -export * from "./list-view"; export * from "./modals"; -export * from "./spreadsheet-view"; -export * from "./theme"; export * from "./sidebar"; -export * from "./issues-view"; -export * from "./image-picker-popover"; +export * from "./theme"; +export * from "./views"; +export * from "./activity"; export * from "./feeds"; +export * from "./reaction-selector"; +export * from "./image-picker-popover"; diff --git a/apps/app/components/core/list-view/all-lists.tsx b/apps/app/components/core/list-view/all-lists.tsx deleted file mode 100644 index fcedf169a..000000000 --- a/apps/app/components/core/list-view/all-lists.tsx +++ /dev/null @@ -1,72 +0,0 @@ -// hooks -import useIssuesView from "hooks/use-issues-view"; -// components -import { SingleList } from "components/core/list-view/single-list"; -// types -import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types"; - -// types -type Props = { - type: "issue" | "cycle" | "module"; - states: IState[] | undefined; - addIssueToState: (groupTitle: string) => void; - makeIssueCopy: (issue: IIssue) => void; - handleEditIssue: (issue: IIssue) => void; - handleDeleteIssue: (issue: IIssue) => void; - openIssuesListModal?: (() => void) | null; - removeIssue: ((bridgeId: string, issueId: string) => void) | null; - isCompleted?: boolean; - user: ICurrentUserResponse | undefined; - userAuth: UserAuth; -}; - -export const AllLists: React.FC = ({ - type, - states, - addIssueToState, - makeIssueCopy, - openIssuesListModal, - handleEditIssue, - handleDeleteIssue, - removeIssue, - isCompleted = false, - user, - userAuth, -}) => { - const { groupedByIssues, groupByProperty: selectedGroup, showEmptyGroups } = useIssuesView(); - - return ( - <> - {groupedByIssues && ( -
- {Object.keys(groupedByIssues).map((singleGroup) => { - const currentState = - selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null; - - if (!showEmptyGroups && groupedByIssues[singleGroup].length === 0) return null; - - return ( - addIssueToState(singleGroup)} - makeIssueCopy={makeIssueCopy} - handleEditIssue={handleEditIssue} - handleDeleteIssue={handleDeleteIssue} - openIssuesListModal={type !== "issue" ? openIssuesListModal : null} - removeIssue={removeIssue} - isCompleted={isCompleted} - user={user} - userAuth={userAuth} - /> - ); - })} -
- )} - - ); -}; diff --git a/apps/app/components/core/modals/existing-issues-list-modal.tsx b/apps/app/components/core/modals/existing-issues-list-modal.tsx index 39237ce9a..5a9f68d8b 100644 --- a/apps/app/components/core/modals/existing-issues-list-modal.tsx +++ b/apps/app/components/core/modals/existing-issues-list-modal.tsx @@ -13,8 +13,9 @@ import useToast from "hooks/use-toast"; import useIssuesView from "hooks/use-issues-view"; import useDebounce from "hooks/use-debounce"; // ui -import { Loader, PrimaryButton, SecondaryButton } from "components/ui"; +import { Loader, PrimaryButton, SecondaryButton, ToggleSwitch, Tooltip } from "components/ui"; // icons +import { LaunchOutlined } from "@mui/icons-material"; import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { LayerDiagonalIcon } from "components/icons"; // types @@ -32,6 +33,7 @@ type Props = { handleClose: () => void; searchParams: Partial; handleOnSubmit: (data: ISearchIssueResponse[]) => Promise; + workspaceLevelToggle?: boolean; }; export const ExistingIssuesListModal: React.FC = ({ @@ -39,13 +41,14 @@ export const ExistingIssuesListModal: React.FC = ({ handleClose: onClose, searchParams, handleOnSubmit, + workspaceLevelToggle = false, }) => { const [searchTerm, setSearchTerm] = useState(""); const [issues, setIssues] = useState([]); - const [isLoading, setIsLoading] = useState(false); const [isSearching, setIsSearching] = useState(false); const [selectedIssues, setSelectedIssues] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); + const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); const debouncedSearchTerm: string = useDebounce(searchTerm, 500); @@ -60,6 +63,7 @@ export const ExistingIssuesListModal: React.FC = ({ onClose(); setSearchTerm(""); setSelectedIssues([]); + setIsWorkspaceLevel(false); }; const onSubmit = async () => { @@ -97,31 +101,19 @@ export const ExistingIssuesListModal: React.FC = ({ }; useEffect(() => { - if (!workspaceSlug || !projectId) return; + if (!isOpen || !workspaceSlug || !projectId) return; - setIsLoading(true); + setIsSearching(true); - if (debouncedSearchTerm) { - setIsSearching(true); - - projectService - .projectIssuesSearch(workspaceSlug as string, projectId as string, { - search: debouncedSearchTerm, - ...searchParams, - }) - .then((res) => { - setIssues(res); - }) - .finally(() => { - setIsLoading(false); - setIsSearching(false); - }); - } else { - setIssues([]); - setIsLoading(false); - setIsSearching(false); - } - }, [debouncedSearchTerm, workspaceSlug, projectId, searchParams]); + projectService + .projectIssuesSearch(workspaceSlug as string, projectId as string, { + search: debouncedSearchTerm, + ...searchParams, + workspace_search: isWorkspaceLevel, + }) + .then((res) => setIssues(res)) + .finally(() => setIsSearching(false)); + }, [debouncedSearchTerm, isOpen, isWorkspaceLevel, projectId, searchParams, workspaceSlug]); return ( <> @@ -169,14 +161,14 @@ export const ExistingIssuesListModal: React.FC = ({ aria-hidden="true" /> setSearchTerm(e.target.value)} />
-
+
{selectedIssues.length > 0 ? (
{selectedIssues.map((issue) => ( @@ -204,22 +196,43 @@ export const ExistingIssuesListModal: React.FC = ({ No issues selected
)} + {workspaceLevelToggle && ( + +
+ setIsWorkspaceLevel((prevData) => !prevData)} + /> + +
+
+ )}
- - {debouncedSearchTerm !== "" && ( + + {searchTerm !== "" && (
Search results for{" "} {'"'} - {debouncedSearchTerm} + {searchTerm} {'"'} {" "} in project:
)} - {!isLoading && + {!isSearching && issues.length === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( @@ -235,7 +248,7 @@ export const ExistingIssuesListModal: React.FC = ({
)} - {isLoading || isSearching ? ( + {isSearching ? ( @@ -256,22 +269,37 @@ export const ExistingIssuesListModal: React.FC = ({ htmlFor={`issue-${issue.id}`} value={issue} className={({ active }) => - `flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${ + `group flex items-center justify-between gap-2 w-full cursor-pointer select-none rounded-md px-3 py-2 text-custom-text-200 ${ active ? "bg-custom-background-80 text-custom-text-100" : "" } ${selected ? "text-custom-text-100" : ""}` } > - - - - {issue.project__identifier}-{issue.sequence_id} - - {issue.name} +
+ + + + {issue.project__identifier}-{issue.sequence_id} + + {issue.name} +
+ e.stopPropagation()} + > + + ); })} diff --git a/apps/app/components/core/modals/gpt-assistant-modal.tsx b/apps/app/components/core/modals/gpt-assistant-modal.tsx index f1b24c65b..7c05e036a 100644 --- a/apps/app/components/core/modals/gpt-assistant-modal.tsx +++ b/apps/app/components/core/modals/gpt-assistant-modal.tsx @@ -117,18 +117,21 @@ export const GptAssistantModal: React.FC = ({ else setInvalidResponse(false); }) .catch((err) => { + const error = err?.data?.error; + if (err.status === 429) setToastAlert({ type: "error", title: "Error!", message: + error || "You have reached the maximum number of requests of 50 requests per month per user.", }); else setToastAlert({ type: "error", title: "Error!", - message: "Some error occurred. Please try again.", + message: error || "Some error occurred. Please try again.", }); }); }; diff --git a/apps/app/components/core/modals/image-upload-modal.tsx b/apps/app/components/core/modals/image-upload-modal.tsx index 2564f3732..113c5f98d 100644 --- a/apps/app/components/core/modals/image-upload-modal.tsx +++ b/apps/app/components/core/modals/image-upload-modal.tsx @@ -43,8 +43,12 @@ export const ImageUploadModal: React.FC = ({ setImage(acceptedFiles[0]); }, []); - const { getRootProps, getInputProps, isDragActive } = useDropzone({ + const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ onDrop, + accept: { + "image/*": [".png", ".jpg", ".jpeg", ".svg", ".webp"], + }, + maxSize: 5 * 1024 * 1024, }); const handleSubmit = async () => { @@ -166,9 +170,19 @@ export const ImageUploadModal: React.FC = ({
+ {fileRejections.length > 0 && ( +

+ {fileRejections[0].errors[0].code === "file-too-large" + ? "The image size cannot exceed 5 MB." + : "Please upload a file in a valid format."} +

+ )}
-
+

+ File formats supported- .jpeg, .jpg, .png, .webp, .svg +

+
Cancel Promise; }; -const defaultValues: ModuleLink = { +const defaultValues: IIssueLink | ModuleLink = { title: "", url: "", }; @@ -30,9 +30,8 @@ export const LinkModal: React.FC = ({ isOpen, handleClose, onFormSubmit } defaultValues, }); - const onSubmit = async (formData: ModuleLink) => { - await onFormSubmit(formData); - + const onSubmit = async (formData: IIssueLink | ModuleLink) => { + await onFormSubmit({ title: formData.title, url: formData.url }); onClose(); }; @@ -87,7 +86,7 @@ export const LinkModal: React.FC = ({ isOpen, handleClose, onFormSubmit } label="URL" name="url" type="url" - placeholder="Enter URL" + placeholder="https://..." autoComplete="off" error={errors.url} register={register} @@ -99,16 +98,13 @@ export const LinkModal: React.FC = ({ isOpen, handleClose, onFormSubmit }
@@ -116,7 +112,7 @@ export const LinkModal: React.FC = ({ isOpen, handleClose, onFormSubmit }
Cancel - + {isSubmitting ? "Adding Link..." : "Add Link"}
diff --git a/apps/app/components/core/reaction-selector.tsx b/apps/app/components/core/reaction-selector.tsx new file mode 100644 index 000000000..06b410785 --- /dev/null +++ b/apps/app/components/core/reaction-selector.tsx @@ -0,0 +1,87 @@ +import { Fragment } from "react"; + +// headless ui +import { Popover, Transition } from "@headlessui/react"; + +// helper +import { renderEmoji } from "helpers/emoji.helper"; + +// icons +import { Icon } from "components/ui"; + +const reactionEmojis = [ + "128077", + "128078", + "128516", + "128165", + "128533", + "129505", + "9992", + "128064", +]; + +interface Props { + size?: "sm" | "md" | "lg"; + position?: "top" | "bottom"; + value?: string | string[] | null; + onSelect: (emoji: string) => void; +} + +export const ReactionSelector: React.FC = (props) => { + const { value, onSelect, position, size } = props; + + return ( + + {({ open, close: closePopover }) => ( + <> + + + + + + + +
+
+ {reactionEmojis.map((emoji) => ( + + ))} +
+
+
+
+ + )} +
+ ); +}; diff --git a/apps/app/components/core/sidebar/links-list.tsx b/apps/app/components/core/sidebar/links-list.tsx index a0619b924..e9f8e9039 100644 --- a/apps/app/components/core/sidebar/links-list.tsx +++ b/apps/app/components/core/sidebar/links-list.tsx @@ -1,5 +1,3 @@ -import Link from "next/link"; - // icons import { ArrowTopRightOnSquareIcon, LinkIcon, TrashIcon } from "@heroicons/react/24/outline"; // helpers @@ -30,14 +28,14 @@ export const LinksList: React.FC = ({ links, handleDeleteLink, userAuth } ))} diff --git a/apps/app/components/core/views/all-views.tsx b/apps/app/components/core/views/all-views.tsx new file mode 100644 index 000000000..4a757649c --- /dev/null +++ b/apps/app/components/core/views/all-views.tsx @@ -0,0 +1,201 @@ +import React, { useCallback } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// react-beautiful-dnd +import { DragDropContext, DropResult } from "react-beautiful-dnd"; +import StrictModeDroppable from "components/dnd/StrictModeDroppable"; +// services +import stateService from "services/state.service"; +// hooks +import useUser from "hooks/use-user"; +import { useProjectMyMembership } from "contexts/project-member.context"; +// components +import { + AllLists, + AllBoards, + CalendarView, + SpreadsheetView, + GanttChartView, +} from "components/core"; +// ui +import { EmptyState, Spinner } from "components/ui"; +// icons +import { TrashIcon } from "@heroicons/react/24/outline"; +// images +import emptyIssue from "public/empty-state/issue.svg"; +import emptyIssueArchive from "public/empty-state/issue-archive.svg"; +// helpers +import { getStatesList } from "helpers/state.helper"; +// types +import { IIssue, IIssueViewProps } from "types"; +// fetch-keys +import { STATES_LIST } from "constants/fetch-keys"; + +type Props = { + addIssueToDate: (date: string) => void; + addIssueToGroup: (groupTitle: string) => void; + disableUserActions: boolean; + dragDisabled?: boolean; + emptyState: { + title: string; + description?: string; + primaryButton?: { + icon: any; + text: string; + onClick: () => void; + }; + secondaryButton?: React.ReactNode; + }; + handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; + handleOnDragEnd: (result: DropResult) => Promise; + openIssuesListModal: (() => void) | null; + removeIssue: ((bridgeId: string, issueId: string) => void) | null; + trashBox: boolean; + setTrashBox: React.Dispatch>; + viewProps: IIssueViewProps; +}; + +export const AllViews: React.FC = ({ + addIssueToDate, + addIssueToGroup, + disableUserActions, + dragDisabled = false, + emptyState, + handleIssueAction, + handleOnDragEnd, + openIssuesListModal, + removeIssue, + trashBox, + setTrashBox, + viewProps, +}) => { + const router = useRouter(); + const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + + const { user } = useUser(); + const { memberRole } = useProjectMyMembership(); + + const { groupedIssues, isEmpty, issueView } = viewProps; + + const { data: stateGroups } = useSWR( + workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, + workspaceSlug + ? () => stateService.getStates(workspaceSlug as string, projectId as string) + : null + ); + const states = getStatesList(stateGroups); + + const handleTrashBox = useCallback( + (isDragging: boolean) => { + if (isDragging && !trashBox) setTrashBox(true); + }, + [trashBox, setTrashBox] + ); + + return ( + + + {(provided, snapshot) => ( +
+ + Drop here to delete the issue. +
+ )} +
+ {groupedIssues ? ( + !isEmpty || issueView === "kanban" || issueView === "calendar" ? ( + <> + {issueView === "list" ? ( + + ) : issueView === "kanban" ? ( + + ) : issueView === "calendar" ? ( + + ) : issueView === "spreadsheet" ? ( + + ) : ( + issueView === "gantt_chart" && + )} + + ) : router.pathname.includes("archived-issues") ? ( + { + router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`); + }, + }} + /> + ) : ( + + ) + ) : ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/app/components/core/board-view/all-boards.tsx b/apps/app/components/core/views/board-view/all-boards.tsx similarity index 66% rename from apps/app/components/core/board-view/all-boards.tsx rename to apps/app/components/core/views/board-view/all-boards.tsx index 003083aa9..ee0fc668b 100644 --- a/apps/app/components/core/board-view/all-boards.tsx +++ b/apps/app/components/core/views/board-view/all-boards.tsx @@ -1,75 +1,66 @@ -// hooks -import useProjectIssuesView from "hooks/use-issues-view"; // components -import { SingleBoard } from "components/core/board-view/single-board"; +import { SingleBoard } from "components/core/views/board-view/single-board"; // icons import { getStateGroupIcon } from "components/icons"; // helpers import { addSpaceIfCamelCase } from "helpers/string.helper"; // types -import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types"; +import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types"; type Props = { - type: "issue" | "cycle" | "module"; - states: IState[] | undefined; - addIssueToState: (groupTitle: string) => void; - makeIssueCopy: (issue: IIssue) => void; - handleEditIssue: (issue: IIssue) => void; - openIssuesListModal?: (() => void) | null; - handleDeleteIssue: (issue: IIssue) => void; + addIssueToGroup: (groupTitle: string) => void; + disableUserActions: boolean; + dragDisabled: boolean; + handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleTrashBox: (isDragging: boolean) => void; + openIssuesListModal?: (() => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null; - isCompleted?: boolean; + states: IState[] | undefined; user: ICurrentUserResponse | undefined; userAuth: UserAuth; + viewProps: IIssueViewProps; }; export const AllBoards: React.FC = ({ - type, - states, - addIssueToState, - makeIssueCopy, - handleEditIssue, - openIssuesListModal, - handleDeleteIssue, + addIssueToGroup, + disableUserActions, + dragDisabled, + handleIssueAction, handleTrashBox, + openIssuesListModal, removeIssue, - isCompleted = false, + states, user, userAuth, + viewProps, }) => { - const { - groupedByIssues, - groupByProperty: selectedGroup, - showEmptyGroups, - } = useProjectIssuesView(); + const { groupByProperty: selectedGroup, groupedIssues, showEmptyGroups } = viewProps; return ( <> - {groupedByIssues ? ( + {groupedIssues ? (
- {Object.keys(groupedByIssues).map((singleGroup, index) => { + {Object.keys(groupedIssues).map((singleGroup, index) => { const currentState = selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null; - if (!showEmptyGroups && groupedByIssues[singleGroup].length === 0) return null; + if (!showEmptyGroups && groupedIssues[singleGroup].length === 0) return null; return ( addIssueToGroup(singleGroup)} currentState={currentState} + disableUserActions={disableUserActions} + dragDisabled={dragDisabled} groupTitle={singleGroup} - handleEditIssue={handleEditIssue} - makeIssueCopy={makeIssueCopy} - addIssueToState={() => addIssueToState(singleGroup)} - handleDeleteIssue={handleDeleteIssue} - openIssuesListModal={openIssuesListModal ?? null} + handleIssueAction={handleIssueAction} handleTrashBox={handleTrashBox} + openIssuesListModal={openIssuesListModal ?? null} removeIssue={removeIssue} - isCompleted={isCompleted} user={user} userAuth={userAuth} + viewProps={viewProps} /> ); })} @@ -77,11 +68,11 @@ export const AllBoards: React.FC = ({

Hidden groups

- {Object.keys(groupedByIssues).map((singleGroup, index) => { + {Object.keys(groupedIssues).map((singleGroup, index) => { const currentState = selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null; - if (groupedByIssues[singleGroup].length === 0) + if (groupedIssues[singleGroup].length === 0) return (
void; + addIssueToGroup: () => void; isCollapsed: boolean; setIsCollapsed: React.Dispatch>; - isCompleted?: boolean; + disableUserActions: boolean; + viewProps: IIssueViewProps; }; export const BoardHeader: React.FC = ({ currentState, groupTitle, - addIssueToState, + addIssueToGroup, isCollapsed, setIsCollapsed, - isCompleted = false, + disableUserActions, + viewProps, }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; - const { groupedByIssues, groupByProperty: selectedGroup } = useIssuesView(); + const { groupedIssues, groupByProperty: selectedGroup } = viewProps; - const { data: issueLabels } = useSWR( - workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, - workspaceSlug && projectId - ? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string) + const { data: issueLabels } = useSWR( + workspaceSlug && projectId && selectedGroup === "labels" + ? PROJECT_ISSUE_LABELS(projectId.toString()) + : null, + workspaceSlug && projectId && selectedGroup === "labels" + ? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString()) : null ); const { data: members } = useSWR( - workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, - workspaceSlug && projectId - ? () => projectService.projectMembers(workspaceSlug as string, projectId as string) + workspaceSlug && projectId && selectedGroup === "created_by" + ? PROJECT_MEMBERS(projectId.toString()) + : null, + workspaceSlug && projectId && selectedGroup === "created_by" + ? () => projectService.projectMembers(workspaceSlug.toString(), projectId.toString()) : null ); + const { projects } = useProjects(); + const getGroupTitle = () => { let title = addSpaceIfCamelCase(groupTitle); @@ -67,6 +76,9 @@ export const BoardHeader: React.FC = ({ case "labels": title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None"; break; + case "project": + title = projects?.find((p) => p.id === groupTitle)?.name ?? "None"; + break; case "created_by": const member = members?.find((member) => member.member.id === groupTitle)?.member; title = @@ -87,9 +99,22 @@ export const BoardHeader: React.FC = ({ icon = currentState && getStateGroupIcon(currentState.group, "16", "16", currentState.color); break; + case "state_detail.group": + icon = getStateGroupIcon(groupTitle as any, "16", "16"); + break; case "priority": icon = getPriorityIcon(groupTitle, "text-lg"); break; + case "project": + const project = projects?.find((p) => p.id === groupTitle); + icon = + project && + (project.emoji !== null + ? renderEmoji(project.emoji) + : project.icon_prop !== null + ? renderEmoji(project.icon_prop) + : null); + break; case "labels": const labelColor = issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000"; @@ -116,7 +141,7 @@ export const BoardHeader: React.FC = ({ !isCollapsed ? "flex-col rounded-md bg-custom-background-90" : "" }`} > -
+
= ({

{getGroupTitle()} @@ -136,7 +161,7 @@ export const BoardHeader: React.FC = ({ isCollapsed ? "ml-0.5" : "" } min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs`} > - {groupedByIssues?.[groupTitle].length ?? 0} + {groupedIssues?.[groupTitle].length ?? 0}

@@ -155,11 +180,11 @@ export const BoardHeader: React.FC = ({ )} - {!isCompleted && selectedGroup !== "created_by" && ( + {!disableUserActions && selectedGroup !== "created_by" && ( diff --git a/apps/app/components/core/board-view/index.ts b/apps/app/components/core/views/board-view/index.ts similarity index 100% rename from apps/app/components/core/board-view/index.ts rename to apps/app/components/core/views/board-view/index.ts diff --git a/apps/app/components/core/board-view/single-board.tsx b/apps/app/components/core/views/board-view/single-board.tsx similarity index 78% rename from apps/app/components/core/board-view/single-board.tsx rename to apps/app/components/core/views/board-view/single-board.tsx index 5701f4c58..3380dd1ec 100644 --- a/apps/app/components/core/board-view/single-board.tsx +++ b/apps/app/components/core/views/board-view/single-board.tsx @@ -5,9 +5,6 @@ import { useRouter } from "next/router"; // react-beautiful-dnd import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import { Draggable } from "react-beautiful-dnd"; -// hooks -import useIssuesView from "hooks/use-issues-view"; -import useIssuesProperties from "hooks/use-issue-properties"; // components import { BoardHeader, SingleBoardIssue } from "components/core"; // ui @@ -17,64 +14,63 @@ import { PlusIcon } from "@heroicons/react/24/outline"; // helpers import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; // types -import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types"; +import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types"; type Props = { - type?: "issue" | "cycle" | "module"; + addIssueToGroup: () => void; currentState?: IState | null; + disableUserActions: boolean; + dragDisabled: boolean; groupTitle: string; - handleEditIssue: (issue: IIssue) => void; - makeIssueCopy: (issue: IIssue) => void; - addIssueToState: () => void; - handleDeleteIssue: (issue: IIssue) => void; - openIssuesListModal?: (() => void) | null; + handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; handleTrashBox: (isDragging: boolean) => void; + openIssuesListModal?: (() => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null; - isCompleted?: boolean; user: ICurrentUserResponse | undefined; userAuth: UserAuth; + viewProps: IIssueViewProps; }; export const SingleBoard: React.FC = ({ - type, + addIssueToGroup, currentState, groupTitle, - handleEditIssue, - makeIssueCopy, - addIssueToState, - handleDeleteIssue, - openIssuesListModal, + disableUserActions, + dragDisabled, + handleIssueAction, handleTrashBox, + openIssuesListModal, removeIssue, - isCompleted = false, user, userAuth, + viewProps, }) => { // collapse/expand const [isCollapsed, setIsCollapsed] = useState(true); - const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssuesView(); + const { groupedIssues, groupByProperty: selectedGroup, orderBy, properties } = viewProps; const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { cycleId, moduleId } = router.query; - const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); + const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; // Check if it has at least 4 tickets since it is enough to accommodate the Calendar height - const issuesLength = groupedByIssues?.[groupTitle].length; + const issuesLength = groupedIssues?.[groupTitle].length; const hasMinimumNumberOfCards = issuesLength ? issuesLength >= 4 : false; - const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted; + const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; return (
{isCollapsed && ( @@ -112,14 +108,12 @@ export const SingleBoard: React.FC = ({ hasMinimumNumberOfCards ? "overflow-hidden overflow-y-scroll" : "" } `} > - {groupedByIssues?.[groupTitle].map((issue, index) => ( + {groupedIssues?.[groupTitle].map((issue, index) => ( {(provided, snapshot) => ( = ({ snapshot={snapshot} type={type} index={index} - selectedGroup={selectedGroup} issue={issue} groupTitle={groupTitle} - properties={properties} - editIssue={() => handleEditIssue(issue)} - makeIssueCopy={() => makeIssueCopy(issue)} - handleDeleteIssue={handleDeleteIssue} + editIssue={() => handleIssueAction(issue, "edit")} + makeIssueCopy={() => handleIssueAction(issue, "copy")} + handleDeleteIssue={() => handleIssueAction(issue, "delete")} handleTrashBox={handleTrashBox} removeIssue={() => { if (removeIssue && issue.bridge_id) removeIssue(issue.bridge_id, issue.id); }} - isCompleted={isCompleted} + disableUserActions={disableUserActions} user={user} userAuth={userAuth} + viewProps={viewProps} /> )} @@ -161,18 +154,18 @@ export const SingleBoard: React.FC = ({ ) : ( - !isCompleted && ( + !disableUserActions && ( Add Issue @@ -181,7 +174,7 @@ export const SingleBoard: React.FC = ({ position="left" noBorder > - + Create new {openIssuesListModal && ( diff --git a/apps/app/components/core/board-view/single-issue.tsx b/apps/app/components/core/views/board-view/single-issue.tsx similarity index 88% rename from apps/app/components/core/board-view/single-issue.tsx rename to apps/app/components/core/views/board-view/single-issue.tsx index bd87dde6c..bfae63a2c 100644 --- a/apps/app/components/core/board-view/single-issue.tsx +++ b/apps/app/components/core/views/board-view/single-issue.tsx @@ -15,7 +15,6 @@ import { // services import issuesService from "services/issues.service"; // hooks -import useIssuesView from "hooks/use-issues-view"; import useToast from "hooks/use-toast"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components @@ -23,7 +22,7 @@ import { ViewAssigneeSelect, ViewDueDateSelect, ViewEstimateSelect, - ViewLabelSelect, + ViewIssueLabel, ViewPrioritySelect, ViewStateSelect, } from "components/issues"; @@ -45,42 +44,26 @@ import { LayerDiagonalIcon } from "components/icons"; import { handleIssuesMutation } from "constants/issue"; import { copyTextToClipboard } from "helpers/string.helper"; // types -import { - ICurrentUserResponse, - IIssue, - ISubIssueResponse, - Properties, - TIssueGroupByOptions, - UserAuth, -} from "types"; +import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types"; // fetch-keys -import { - CYCLE_DETAILS, - CYCLE_ISSUES_WITH_PARAMS, - MODULE_DETAILS, - MODULE_ISSUES_WITH_PARAMS, - PROJECT_ISSUES_LIST_WITH_PARAMS, - SUB_ISSUES, - VIEW_ISSUES, -} from "constants/fetch-keys"; +import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys"; type Props = { type?: string; provided: DraggableProvided; snapshot: DraggableStateSnapshot; issue: IIssue; - properties: Properties; groupTitle?: string; index: number; - selectedGroup: TIssueGroupByOptions; editIssue: () => void; makeIssueCopy: () => void; removeIssue?: (() => void) | null; handleDeleteIssue: (issue: IIssue) => void; handleTrashBox: (isDragging: boolean) => void; - isCompleted?: boolean; + disableUserActions: boolean; user: ICurrentUserResponse | undefined; userAuth: UserAuth; + viewProps: IIssueViewProps; }; export const SingleBoardIssue: React.FC = ({ @@ -88,18 +71,17 @@ export const SingleBoardIssue: React.FC = ({ provided, snapshot, issue, - properties, index, - selectedGroup, editIssue, makeIssueCopy, removeIssue, groupTitle, handleDeleteIssue, handleTrashBox, - isCompleted = false, + disableUserActions, user, userAuth, + viewProps, }) => { // context menu const [contextMenu, setContextMenu] = useState(false); @@ -108,24 +90,16 @@ export const SingleBoardIssue: React.FC = ({ const actionSectionRef = useRef(null); - const { orderBy, params } = useIssuesView(); + const { groupByProperty: selectedGroup, orderBy, properties, mutateIssues } = viewProps; const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { setToastAlert } = useToast(); const partialUpdateIssue = useCallback( (formData: Partial, issue: IIssue) => { - if (!workspaceSlug || !projectId) return; - - const fetchKey = cycleId - ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) - : moduleId - ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) - : viewId - ? VIEW_ISSUES(viewId.toString(), params) - : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params); + if (!workspaceSlug || !issue) return; if (issue.parent) { mutate( @@ -149,13 +123,7 @@ export const SingleBoardIssue: React.FC = ({ false ); } else { - mutate< - | { - [key: string]: IIssue[]; - } - | IIssue[] - >( - fetchKey, + mutateIssues( (prevData) => handleIssuesMutation( formData, @@ -170,9 +138,9 @@ export const SingleBoardIssue: React.FC = ({ } issuesService - .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user) + .patchIssue(workspaceSlug as string, issue.project, issue.id, formData, user) .then(() => { - mutate(fetchKey); + mutateIssues(); if (cycleId) mutate(CYCLE_DETAILS(cycleId as string)); if (moduleId) mutate(MODULE_DETAILS(moduleId as string)); @@ -180,15 +148,13 @@ export const SingleBoardIssue: React.FC = ({ }, [ workspaceSlug, - projectId, cycleId, moduleId, - viewId, groupTitle, index, selectedGroup, + mutateIssues, orderBy, - params, user, ] ); @@ -228,7 +194,7 @@ export const SingleBoardIssue: React.FC = ({ useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); - const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted; + const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; return ( <> @@ -255,7 +221,7 @@ export const SingleBoardIssue: React.FC = ({ Copy issue link @@ -365,13 +331,7 @@ export const SingleBoardIssue: React.FC = ({ /> )} {properties.labels && issue.labels.length > 0 && ( - + )} {properties.assignee && ( void; - handleDeleteIssue: (issue: IIssue) => void; + handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; addIssueToDate: (date: string) => void; - isCompleted: boolean; + disableUserActions: boolean; user: ICurrentUserResponse | undefined; userAuth: UserAuth; }; export const CalendarView: React.FC = ({ - handleEditIssue, - handleDeleteIssue, + handleIssueAction, addIssueToDate, - isCompleted = false, + disableUserActions, user, userAuth, }) => { @@ -167,7 +165,7 @@ export const CalendarView: React.FC = ({ ); }, [currentDate]); - const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted; + const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; return calendarIssues ? (
@@ -220,10 +218,10 @@ export const CalendarView: React.FC = ({ > {currentViewDaysData.map((date, index) => ( void; - handleDeleteIssue: (issue: IIssue) => void; + handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; index: number; date: { date: string; @@ -28,8 +27,7 @@ type Props = { }; export const SingleCalendarDate: React.FC = ({ - handleEditIssue, - handleDeleteIssue, + handleIssueAction, date, index, addIssueToDate, @@ -72,8 +70,8 @@ export const SingleCalendarDate: React.FC = ({ provided={provided} snapshot={snapshot} issue={issue} - handleEditIssue={handleEditIssue} - handleDeleteIssue={handleDeleteIssue} + handleEditIssue={() => handleIssueAction(issue, "edit")} + handleDeleteIssue={() => handleIssueAction(issue, "delete")} user={user} isNotAllowed={isNotAllowed} /> diff --git a/apps/app/components/core/calendar-view/single-issue.tsx b/apps/app/components/core/views/calendar-view/single-issue.tsx similarity index 99% rename from apps/app/components/core/calendar-view/single-issue.tsx rename to apps/app/components/core/views/calendar-view/single-issue.tsx index 9a22a9a59..319fd74db 100644 --- a/apps/app/components/core/calendar-view/single-issue.tsx +++ b/apps/app/components/core/views/calendar-view/single-issue.tsx @@ -192,7 +192,7 @@ export const SingleCalendarIssue: React.FC = ({
)} - +
{properties.key && ( void; - isCompleted?: boolean; + disableUserActions?: boolean; }; export const IssuesView: React.FC = ({ - type = "issue", openIssuesListModal, - isCompleted = false, + disableUserActions = false, }) => { // create issue modal const [createIssueModal, setCreateIssueModal] = useState(false); @@ -83,20 +70,16 @@ export const IssuesView: React.FC = ({ // trash box const [trashBox, setTrashBox] = useState(false); - // transfer issue - const [transferIssuesModal, setTransferIssuesModal] = useState(false); - const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; - const { memberRole } = useProjectMyMembership(); - const { user } = useUserAuth(); const { setToastAlert } = useToast(); const { groupedByIssues, + mutateIssues, issueView, groupByProperty: selectedGroup, orderBy, @@ -104,7 +87,9 @@ export const IssuesView: React.FC = ({ isEmpty, setFilters, params, + showEmptyGroups, } = useIssuesView(); + const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); const { data: stateGroups } = useSWR( workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, @@ -112,7 +97,16 @@ export const IssuesView: React.FC = ({ ? () => stateService.getStates(workspaceSlug as string, projectId as string) : null ); - const states = getStatesList(stateGroups ?? {}); + const states = getStatesList(stateGroups); + + const { data: labels } = useSWR( + workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null, + workspaceSlug && projectId + ? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString()) + : null + ); + + const { members } = useProjectMembers(workspaceSlug?.toString(), projectId?.toString()); const handleDeleteIssue = useCallback( (issue: IIssue) => { @@ -123,7 +117,7 @@ export const IssuesView: React.FC = ({ ); const handleOnDragEnd = useCallback( - (result: DropResult) => { + async (result: DropResult) => { setTrashBox(false); if (!result.destination || !workspaceSlug || !projectId || !groupedByIssues) return; @@ -191,7 +185,10 @@ export const IssuesView: React.FC = ({ // dragged item(or issue) if (selectedGroup === "priority") draggedItem.priority = destinationGroup; - else if (selectedGroup === "state") draggedItem.state = destinationGroup; + else if (selectedGroup === "state") { + draggedItem.state = destinationGroup; + draggedItem.state_detail = states?.find((s) => s.id === destinationGroup) as IState; + } } const sourceGroup = source.droppableId; // source group id @@ -207,8 +204,8 @@ export const IssuesView: React.FC = ({ (prevData) => { if (!prevData) return prevData; - const sourceGroupArray = prevData[sourceGroup]; - const destinationGroupArray = groupedByIssues[destinationGroup]; + const sourceGroupArray = [...groupedByIssues[sourceGroup]]; + const destinationGroupArray = [...groupedByIssues[destinationGroup]]; sourceGroupArray.splice(source.index, 1); destinationGroupArray.splice(destination.index, 0, draggedItem); @@ -236,7 +233,9 @@ export const IssuesView: React.FC = ({ user ) .then((response) => { - const sourceStateBeforeDrag = states.find((state) => state.name === source.droppableId); + const sourceStateBeforeDrag = states?.find( + (state) => state.name === source.droppableId + ); if ( sourceStateBeforeDrag?.group !== "completed" && @@ -281,7 +280,7 @@ export const IssuesView: React.FC = ({ ] ); - const addIssueToState = useCallback( + const addIssueToGroup = useCallback( (groupTitle: string) => { setCreateIssueModal(true); @@ -335,6 +334,15 @@ export const IssuesView: React.FC = ({ [setEditIssueModal, setIssueToEdit] ); + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + const removeIssueFromCycle = useCallback( (bridgeId: string, issueId: string) => { if (!workspaceSlug || !projectId || !cycleId) return; @@ -421,13 +429,6 @@ export const IssuesView: React.FC = ({ [workspaceSlug, projectId, moduleId, params, selectedGroup, setToastAlert] ); - const handleTrashBox = useCallback( - (isDragging: boolean) => { - if (isDragging && !trashBox) setTrashBox(true); - }, - [trashBox, setTrashBox] - ); - const nullFilters = Object.keys(filters).filter( (key) => filters[key as keyof IIssueFilterOptions] === null ); @@ -461,14 +462,27 @@ export const IssuesView: React.FC = ({ data={issueToDelete} user={user} /> - setTransferIssuesModal(false)} - isOpen={transferIssuesModal} - /> {areFiltersApplied && ( <>
- + setFilters(updatedFilter, !Boolean(viewId))} + labels={labels} + members={members?.map((m) => m.member)} + states={states} + clearAllFilters={() => + setFilters({ + assignees: null, + created_by: null, + labels: null, + priority: null, + state: null, + target_date: null, + type: null, + }) + } + /> { if (viewId) { @@ -492,129 +506,62 @@ export const IssuesView: React.FC = ({ {
} )} - - - - {(provided, snapshot) => ( -
- - Drop here to delete the issue. -
- )} -
- {groupedByIssues ? ( - !isEmpty || issueView === "kanban" || issueView === "calendar" ? ( - <> - {isCompleted && setTransferIssuesModal(true)} />} - {issueView === "list" ? ( - - ) : issueView === "kanban" ? ( - - ) : issueView === "calendar" ? ( - - ) : issueView === "spreadsheet" ? ( - - ) : ( - issueView === "gantt_chart" && - )} - - ) : router.pathname.includes("archived-issues") ? ( - { - router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`); - }} - /> - ) : ( - } - onClick={() => { - const e = new KeyboardEvent("keydown", { - key: "c", - }); - document.dispatchEvent(e); - }} - /> - ) - ) : ( -
- -
- )} -
+ , + text: "New Issue", + onClick: () => { + const e = new KeyboardEvent("keydown", { + key: "c", + }); + document.dispatchEvent(e); + }, + }, + secondaryButton: + cycleId || moduleId ? ( + {})} + > + + Add an existing issue + + ) : null, + }} + handleOnDragEnd={handleOnDragEnd} + handleIssueAction={handleIssueAction} + openIssuesListModal={openIssuesListModal ? openIssuesListModal : null} + removeIssue={cycleId ? removeIssueFromCycle : moduleId ? removeIssueFromModule : null} + trashBox={trashBox} + setTrashBox={setTrashBox} + viewProps={{ + groupByProperty: selectedGroup, + groupedIssues: groupedByIssues, + isEmpty, + issueView, + mutateIssues, + orderBy, + params, + properties, + showEmptyGroups, + }} + /> ); }; diff --git a/apps/app/components/core/views/list-view/all-lists.tsx b/apps/app/components/core/views/list-view/all-lists.tsx new file mode 100644 index 000000000..64cbebdcd --- /dev/null +++ b/apps/app/components/core/views/list-view/all-lists.tsx @@ -0,0 +1,62 @@ +// components +import { SingleList } from "components/core/views/list-view/single-list"; +// types +import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types"; + +// types +type Props = { + states: IState[] | undefined; + addIssueToGroup: (groupTitle: string) => void; + handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; + openIssuesListModal?: (() => void) | null; + removeIssue: ((bridgeId: string, issueId: string) => void) | null; + disableUserActions: boolean; + user: ICurrentUserResponse | undefined; + userAuth: UserAuth; + viewProps: IIssueViewProps; +}; + +export const AllLists: React.FC = ({ + addIssueToGroup, + handleIssueAction, + disableUserActions, + openIssuesListModal, + removeIssue, + states, + user, + userAuth, + viewProps, +}) => { + const { groupByProperty: selectedGroup, groupedIssues, showEmptyGroups } = viewProps; + + return ( + <> + {groupedIssues && ( +
+ {Object.keys(groupedIssues).map((singleGroup) => { + const currentState = + selectedGroup === "state" ? states?.find((s) => s.id === singleGroup) : null; + + if (!showEmptyGroups && groupedIssues[singleGroup].length === 0) return null; + + return ( + addIssueToGroup(singleGroup)} + handleIssueAction={handleIssueAction} + openIssuesListModal={openIssuesListModal} + removeIssue={removeIssue} + disableUserActions={disableUserActions} + user={user} + userAuth={userAuth} + viewProps={viewProps} + /> + ); + })} +
+ )} + + ); +}; diff --git a/apps/app/components/core/list-view/index.ts b/apps/app/components/core/views/list-view/index.ts similarity index 100% rename from apps/app/components/core/list-view/index.ts rename to apps/app/components/core/views/list-view/index.ts diff --git a/apps/app/components/core/list-view/single-issue.tsx b/apps/app/components/core/views/list-view/single-issue.tsx similarity index 79% rename from apps/app/components/core/list-view/single-issue.tsx rename to apps/app/components/core/views/list-view/single-issue.tsx index 7e397fb74..cdb1b67d8 100644 --- a/apps/app/components/core/list-view/single-issue.tsx +++ b/apps/app/components/core/views/list-view/single-issue.tsx @@ -14,12 +14,10 @@ import { ViewAssigneeSelect, ViewDueDateSelect, ViewEstimateSelect, - ViewLabelSelect, + ViewIssueLabel, ViewPrioritySelect, ViewStateSelect, -} from "components/issues/view-select"; -// hooks -import useIssueView from "hooks/use-issues-view"; +} from "components/issues"; // ui import { Tooltip, CustomMenu, ContextMenu } from "components/ui"; // icons @@ -37,70 +35,54 @@ import { LayerDiagonalIcon } from "components/icons"; import { copyTextToClipboard, truncateText } from "helpers/string.helper"; import { handleIssuesMutation } from "constants/issue"; // types -import { ICurrentUserResponse, IIssue, ISubIssueResponse, Properties, UserAuth } from "types"; +import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types"; // fetch-keys -import { - CYCLE_DETAILS, - CYCLE_ISSUES_WITH_PARAMS, - MODULE_DETAILS, - MODULE_ISSUES_WITH_PARAMS, - PROJECT_ISSUES_LIST_WITH_PARAMS, - SUB_ISSUES, - VIEW_ISSUES, -} from "constants/fetch-keys"; +import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys"; type Props = { type?: string; issue: IIssue; - properties: Properties; groupTitle?: string; editIssue: () => void; index: number; makeIssueCopy: () => void; removeIssue?: (() => void) | null; handleDeleteIssue: (issue: IIssue) => void; - isCompleted?: boolean; + disableUserActions: boolean; user: ICurrentUserResponse | undefined; userAuth: UserAuth; + viewProps: IIssueViewProps; }; export const SingleListIssue: React.FC = ({ type, issue, - properties, editIssue, index, makeIssueCopy, removeIssue, groupTitle, handleDeleteIssue, - isCompleted = false, + disableUserActions, user, userAuth, + viewProps, }) => { // context menu const [contextMenu, setContextMenu] = useState(false); const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const isArchivedIssues = router.pathname.includes("archived-issues"); const { setToastAlert } = useToast(); - const { groupByProperty: selectedGroup, orderBy, params } = useIssueView(); + const { groupByProperty: selectedGroup, orderBy, properties, mutateIssues } = viewProps; const partialUpdateIssue = useCallback( (formData: Partial, issue: IIssue) => { - if (!workspaceSlug || !projectId) return; - - const fetchKey = cycleId - ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) - : moduleId - ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) - : viewId - ? VIEW_ISSUES(viewId.toString(), params) - : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params); + if (!workspaceSlug || !issue) return; if (issue.parent) { mutate( @@ -124,13 +106,7 @@ export const SingleListIssue: React.FC = ({ false ); } else { - mutate< - | { - [key: string]: IIssue[]; - } - | IIssue[] - >( - fetchKey, + mutateIssues( (prevData) => handleIssuesMutation( formData, @@ -145,9 +121,9 @@ export const SingleListIssue: React.FC = ({ } issuesService - .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user) + .patchIssue(workspaceSlug as string, issue.project, issue.id, formData, user) .then(() => { - mutate(fetchKey); + mutateIssues(); if (cycleId) mutate(CYCLE_DETAILS(cycleId as string)); if (moduleId) mutate(MODULE_DETAILS(moduleId as string)); @@ -155,15 +131,13 @@ export const SingleListIssue: React.FC = ({ }, [ workspaceSlug, - projectId, cycleId, moduleId, - viewId, groupTitle, index, selectedGroup, + mutateIssues, orderBy, - params, user, ] ); @@ -182,11 +156,12 @@ export const SingleListIssue: React.FC = ({ }); }; - const singleIssuePath = isArchivedIssues - ? `/${workspaceSlug}/projects/${projectId}/archived-issues/${issue.id}` - : `/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`; + const issuePath = isArchivedIssues + ? `/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}` + : `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`; - const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted || isArchivedIssues; + const isNotAllowed = + userAuth.isGuest || userAuth.isViewer || disableUserActions || isArchivedIssues; return ( <> @@ -212,22 +187,22 @@ export const SingleListIssue: React.FC = ({ Copy issue link -
+ Open issue in new tab
{ e.preventDefault(); setContextMenu(true); setContextMenuPosition({ x: e.pageX, y: e.pageY }); }} > - -
@@ -279,15 +252,7 @@ export const SingleListIssue: React.FC = ({ isNotAllowed={isNotAllowed} /> )} - {properties.labels && issue.labels.length > 0 && ( - - )} + {properties.labels && } {properties.assignee && ( void; - makeIssueCopy: (issue: IIssue) => void; - handleEditIssue: (issue: IIssue) => void; - handleDeleteIssue: (issue: IIssue) => void; + addIssueToGroup: () => void; + handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; openIssuesListModal?: (() => void) | null; removeIssue: ((bridgeId: string, issueId: string) => void) | null; - isCompleted?: boolean; + disableUserActions: boolean; user: ICurrentUserResponse | undefined; userAuth: UserAuth; + viewProps: IIssueViewProps; }; export const SingleList: React.FC = ({ - type, currentState, groupTitle, - groupedByIssues, - selectedGroup, - addIssueToState, - makeIssueCopy, - handleEditIssue, - handleDeleteIssue, + addIssueToGroup, + handleIssueAction, openIssuesListModal, removeIssue, - isCompleted = false, + disableUserActions, user, userAuth, + viewProps, }) => { const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const isArchivedIssues = router.pathname.includes("archived-issues"); - const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); + const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; + + const { groupByProperty: selectedGroup, groupedIssues } = viewProps; const { data: issueLabels } = useSWR( workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, @@ -85,6 +79,8 @@ export const SingleList: React.FC = ({ : null ); + const { projects } = useProjects(); + const getGroupTitle = () => { let title = addSpaceIfCamelCase(groupTitle); @@ -95,6 +91,9 @@ export const SingleList: React.FC = ({ case "labels": title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None"; break; + case "project": + title = projects?.find((p) => p.id === groupTitle)?.name ?? "None"; + break; case "created_by": const member = members?.find((member) => member.member.id === groupTitle)?.member; title = @@ -115,9 +114,22 @@ export const SingleList: React.FC = ({ icon = currentState && getStateGroupIcon(currentState.group, "16", "16", currentState.color); break; + case "state_detail.group": + icon = getStateGroupIcon(groupTitle as any, "16", "16"); + break; case "priority": icon = getPriorityIcon(groupTitle, "text-lg"); break; + case "project": + const project = projects?.find((p) => p.id === groupTitle); + icon = + project && + (project.emoji !== null + ? renderEmoji(project.emoji) + : project.icon_prop !== null + ? renderEmoji(project.icon_prop) + : null); + break; case "labels": const labelColor = issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000"; @@ -138,6 +150,8 @@ export const SingleList: React.FC = ({ return icon; }; + if (!groupedIssues) return null; + return ( {({ open }) => ( @@ -156,7 +170,7 @@ export const SingleList: React.FC = ({

All Issues

)} - {groupedByIssues[groupTitle as keyof IIssue].length} + {groupedIssues[groupTitle as keyof IIssue].length}
@@ -166,11 +180,11 @@ export const SingleList: React.FC = ({ - ) : isCompleted ? ( + ) : disableUserActions ? ( "" ) : ( = ({ position="right" noBorder > - Create new + Create new {openIssuesListModal && ( Add an existing issue @@ -201,26 +215,26 @@ export const SingleList: React.FC = ({ leaveTo="transform opacity-0" > - {groupedByIssues[groupTitle] ? ( - groupedByIssues[groupTitle].length > 0 ? ( - groupedByIssues[groupTitle].map((issue, index) => ( + {groupedIssues[groupTitle] ? ( + groupedIssues[groupTitle].length > 0 ? ( + groupedIssues[groupTitle].map((issue, index) => ( handleEditIssue(issue)} - makeIssueCopy={() => makeIssueCopy(issue)} - handleDeleteIssue={handleDeleteIssue} + editIssue={() => handleIssueAction(issue, "edit")} + makeIssueCopy={() => handleIssueAction(issue, "copy")} + handleDeleteIssue={() => handleIssueAction(issue, "delete")} removeIssue={() => { if (removeIssue !== null && issue.bridge_id) removeIssue(issue.bridge_id, issue.id); }} - isCompleted={isCompleted} + disableUserActions={disableUserActions} user={user} userAuth={userAuth} + viewProps={viewProps} /> )) ) : ( diff --git a/apps/app/components/core/spreadsheet-view/index.ts b/apps/app/components/core/views/spreadsheet-view/index.ts similarity index 100% rename from apps/app/components/core/spreadsheet-view/index.ts rename to apps/app/components/core/views/spreadsheet-view/index.ts diff --git a/apps/app/components/core/spreadsheet-view/single-issue.tsx b/apps/app/components/core/views/spreadsheet-view/single-issue.tsx similarity index 96% rename from apps/app/components/core/spreadsheet-view/single-issue.tsx rename to apps/app/components/core/views/spreadsheet-view/single-issue.tsx index e85814ea7..5d9eb7c31 100644 --- a/apps/app/components/core/spreadsheet-view/single-issue.tsx +++ b/apps/app/components/core/views/spreadsheet-view/single-issue.tsx @@ -10,7 +10,7 @@ import { ViewAssigneeSelect, ViewDueDateSelect, ViewEstimateSelect, - ViewLabelSelect, + ViewIssueLabel, ViewPrioritySelect, ViewStateSelect, } from "components/issues"; @@ -53,7 +53,7 @@ type Props = { handleEditIssue: (issue: IIssue) => void; handleDeleteIssue: (issue: IIssue) => void; gridTemplateColumns: string; - isCompleted?: boolean; + disableUserActions: boolean; user: ICurrentUserResponse | undefined; userAuth: UserAuth; nestingLevel: number; @@ -68,7 +68,7 @@ export const SingleSpreadsheetIssue: React.FC = ({ handleEditIssue, handleDeleteIssue, gridTemplateColumns, - isCompleted = false, + disableUserActions, user, userAuth, nestingLevel, @@ -190,7 +190,7 @@ export const SingleSpreadsheetIssue: React.FC = ({ {issue.project_detail?.identifier}-{issue.sequence_id} )} - {!isNotAllowed && !isCompleted && ( + {!isNotAllowed && !disableUserActions && (
= ({ )}
- + {issue.name} @@ -311,15 +311,7 @@ export const SingleSpreadsheetIssue: React.FC = ({ )} {properties.labels && (
- +
)} diff --git a/apps/app/components/core/spreadsheet-view/spreadsheet-columns.tsx b/apps/app/components/core/views/spreadsheet-view/spreadsheet-columns.tsx similarity index 100% rename from apps/app/components/core/spreadsheet-view/spreadsheet-columns.tsx rename to apps/app/components/core/views/spreadsheet-view/spreadsheet-columns.tsx diff --git a/apps/app/components/core/spreadsheet-view/spreadsheet-issues.tsx b/apps/app/components/core/views/spreadsheet-view/spreadsheet-issues.tsx similarity index 80% rename from apps/app/components/core/spreadsheet-view/spreadsheet-issues.tsx rename to apps/app/components/core/views/spreadsheet-view/spreadsheet-issues.tsx index 1e05eba4e..6677e8849 100644 --- a/apps/app/components/core/spreadsheet-view/spreadsheet-issues.tsx +++ b/apps/app/components/core/views/spreadsheet-view/spreadsheet-issues.tsx @@ -8,32 +8,28 @@ import useSubIssue from "hooks/use-sub-issue"; import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; type Props = { - key: string; issue: IIssue; index: number; expandedIssues: string[]; setExpandedIssues: React.Dispatch>; properties: Properties; - handleEditIssue: (issue: IIssue) => void; - handleDeleteIssue: (issue: IIssue) => void; + handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; gridTemplateColumns: string; - isCompleted?: boolean; + disableUserActions: boolean; user: ICurrentUserResponse | undefined; userAuth: UserAuth; nestingLevel?: number; }; export const SpreadsheetIssues: React.FC = ({ - key, index, issue, expandedIssues, setExpandedIssues, gridTemplateColumns, properties, - handleEditIssue, - handleDeleteIssue, - isCompleted = false, + handleIssueAction, + disableUserActions, user, userAuth, nestingLevel = 0, @@ -64,9 +60,9 @@ export const SpreadsheetIssues: React.FC = ({ handleToggleExpand={handleToggleExpand} gridTemplateColumns={gridTemplateColumns} properties={properties} - handleEditIssue={handleEditIssue} - handleDeleteIssue={handleDeleteIssue} - isCompleted={isCompleted} + handleEditIssue={() => handleIssueAction(issue, "edit")} + handleDeleteIssue={() => handleIssueAction(issue, "delete")} + disableUserActions={disableUserActions} user={user} userAuth={userAuth} nestingLevel={nestingLevel} @@ -76,7 +72,7 @@ export const SpreadsheetIssues: React.FC = ({ !isLoading && subIssues && subIssues.length > 0 && - subIssues.map((subIssue: IIssue, subIndex: number) => ( + subIssues.map((subIssue: IIssue) => ( = ({ setExpandedIssues={setExpandedIssues} gridTemplateColumns={gridTemplateColumns} properties={properties} - handleEditIssue={handleEditIssue} - handleDeleteIssue={handleDeleteIssue} - isCompleted={isCompleted} + handleIssueAction={handleIssueAction} + disableUserActions={disableUserActions} user={user} userAuth={userAuth} nestingLevel={nestingLevel + 1} diff --git a/apps/app/components/core/spreadsheet-view/spreadsheet-view.tsx b/apps/app/components/core/views/spreadsheet-view/spreadsheet-view.tsx similarity index 91% rename from apps/app/components/core/spreadsheet-view/spreadsheet-view.tsx rename to apps/app/components/core/views/spreadsheet-view/spreadsheet-view.tsx index 95bd96417..a4f426a23 100644 --- a/apps/app/components/core/spreadsheet-view/spreadsheet-view.tsx +++ b/apps/app/components/core/views/spreadsheet-view/spreadsheet-view.tsx @@ -17,28 +17,26 @@ import { SPREADSHEET_COLUMN } from "constants/spreadsheet"; import { PlusIcon } from "@heroicons/react/24/outline"; type Props = { - type: "issue" | "cycle" | "module"; - handleEditIssue: (issue: IIssue) => void; - handleDeleteIssue: (issue: IIssue) => void; + handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; openIssuesListModal?: (() => void) | null; - isCompleted?: boolean; + disableUserActions: boolean; user: ICurrentUserResponse | undefined; userAuth: UserAuth; }; export const SpreadsheetView: React.FC = ({ - type, - handleEditIssue, - handleDeleteIssue, + handleIssueAction, openIssuesListModal, - isCompleted = false, + disableUserActions, user, userAuth, }) => { const [expandedIssues, setExpandedIssues] = useState([]); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + + const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; const { spreadsheetIssues } = useSpreadsheetIssuesView(); @@ -76,9 +74,8 @@ export const SpreadsheetView: React.FC = ({ setExpandedIssues={setExpandedIssues} gridTemplateColumns={gridTemplateColumns} properties={properties} - handleEditIssue={handleEditIssue} - handleDeleteIssue={handleDeleteIssue} - isCompleted={isCompleted} + handleIssueAction={handleIssueAction} + disableUserActions={disableUserActions} user={user} userAuth={userAuth} /> @@ -99,7 +96,7 @@ export const SpreadsheetView: React.FC = ({ Add Issue ) : ( - !isCompleted && ( + !disableUserActions && ( { if (!cycle) return ( -
-

No active cycle is present.

+
+
+
+ + + + +
+

No active cycle

+ +
); diff --git a/apps/app/components/cycles/cycles-view.tsx b/apps/app/components/cycles/cycles-view.tsx index ad0a8e7d5..6f3fa336a 100644 --- a/apps/app/components/cycles/cycles-view.tsx +++ b/apps/app/components/cycles/cycles-view.tsx @@ -9,6 +9,7 @@ import cyclesService from "services/cycles.service"; // hooks import useToast from "hooks/use-toast"; import useUserAuth from "hooks/use-user-auth"; +import useLocalStorage from "hooks/use-local-storage"; // components import { CreateUpdateCycleModal, @@ -18,11 +19,7 @@ import { SingleCycleList, } from "components/cycles"; // ui -import { EmptyState, Loader } from "components/ui"; -// icons -import { PlusIcon } from "@heroicons/react/24/outline"; -// images -import emptyCycle from "public/empty-state/cycle.svg"; +import { Loader } from "components/ui"; // helpers import { getDateRangeStatus } from "helpers/date-time.helper"; // types @@ -48,6 +45,8 @@ export const CyclesView: React.FC = ({ cycles, viewType }) => { const [deleteCycleModal, setDeleteCycleModal] = useState(false); const [selectedCycleToDelete, setSelectedCycleToDelete] = useState(null); + const { storedValue: cycleTab } = useLocalStorage("cycleTab", "All"); + const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -206,19 +205,48 @@ export const CyclesView: React.FC = ({ cycles, viewType }) => { ) ) : ( - } - onClick={() => { - const e = new KeyboardEvent("keydown", { - key: "q", - }); - document.dispatchEvent(e); - }} - /> +
+
+
+ + + + +
+

+ {cycleTab === "All" + ? "No cycles" + : `No ${cycleTab === "Drafts" ? "draft" : cycleTab?.toLowerCase()} cycles`} +

+ +
+
) ) : viewType === "list" ? ( diff --git a/apps/app/components/cycles/delete-cycle-modal.tsx b/apps/app/components/cycles/delete-cycle-modal.tsx index 00bd8b9ad..35b8e118e 100644 --- a/apps/app/components/cycles/delete-cycle-modal.tsx +++ b/apps/app/components/cycles/delete-cycle-modal.tsx @@ -14,7 +14,7 @@ import { DangerButton, SecondaryButton } from "components/ui"; // icons import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; // types -import type { ICurrentUserResponse, ICycle } from "types"; +import type { ICurrentUserResponse, ICycle, IProject } from "types"; type TConfirmCycleDeletionProps = { isOpen: boolean; setIsOpen: React.Dispatch>; @@ -27,6 +27,7 @@ import { CURRENT_CYCLE_LIST, CYCLES_LIST, DRAFT_CYCLES_LIST, + PROJECT_DETAILS, UPCOMING_CYCLES_LIST, } from "constants/fetch-keys"; import { getDateRangeStatus } from "helpers/date-time.helper"; @@ -50,7 +51,7 @@ export const DeleteCycleModal: React.FC = ({ }; const handleDeletion = async () => { - if (!data || !workspaceSlug) return; + if (!data || !workspaceSlug || !projectId) return; setIsDeleteLoading(true); @@ -85,6 +86,21 @@ export const DeleteCycleModal: React.FC = ({ }, false ); + + // update total cycles count in the project details + mutate( + PROJECT_DETAILS(projectId.toString()), + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + total_cycles: prevData.total_cycles - 1, + }; + }, + false + ); + handleClose(); setToastAlert({ diff --git a/apps/app/components/cycles/form.tsx b/apps/app/components/cycles/form.tsx index 071577b3f..4643a58bc 100644 --- a/apps/app/components/cycles/form.tsx +++ b/apps/app/components/cycles/form.tsx @@ -29,16 +29,13 @@ export const CycleForm: React.FC = ({ handleFormSubmit, handleClose, stat handleSubmit, control, reset, + watch, } = useForm({ defaultValues, }); const handleCreateUpdateCycle = async (formData: Partial) => { await handleFormSubmit(formData); - - reset({ - ...defaultValues, - }); }; useEffect(() => { @@ -48,6 +45,15 @@ export const CycleForm: React.FC = ({ handleFormSubmit, handleClose, stat }); }, [data, reset]); + const startDate = watch("start_date"); + const endDate = watch("end_date"); + + const minDate = startDate ? new Date(startDate) : new Date(); + minDate.setDate(minDate.getDate() + 1); + + const maxDate = endDate ? new Date(endDate) : null; + maxDate?.setDate(maxDate.getDate() - 1); + return (
@@ -91,7 +97,13 @@ export const CycleForm: React.FC = ({ handleFormSubmit, handleClose, stat control={control} name="start_date" render={({ field: { value, onChange } }) => ( - onChange(val)} /> + onChange(val)} + minDate={new Date()} + maxDate={maxDate ?? undefined} + /> )} />
@@ -100,7 +112,12 @@ export const CycleForm: React.FC = ({ handleFormSubmit, handleClose, stat control={control} name="end_date" render={({ field: { value, onChange } }) => ( - onChange(val)} /> + onChange(val)} + minDate={minDate} + /> )} />
diff --git a/apps/app/components/cycles/modal.tsx b/apps/app/components/cycles/modal.tsx index c83d1d851..703636256 100644 --- a/apps/app/components/cycles/modal.tsx +++ b/apps/app/components/cycles/modal.tsx @@ -13,9 +13,9 @@ import useToast from "hooks/use-toast"; // components import { CycleForm } from "components/cycles"; // helper -import { getDateRangeStatus, isDateGreaterThanToday } from "helpers/date-time.helper"; +import { getDateRangeStatus } from "helpers/date-time.helper"; // types -import type { ICurrentUserResponse, ICycle } from "types"; +import type { CycleDateCheckData, ICurrentUserResponse, ICycle, IProject } from "types"; // fetch keys import { COMPLETED_CYCLES_LIST, @@ -23,6 +23,7 @@ import { CYCLES_LIST, DRAFT_CYCLES_LIST, INCOMPLETE_CYCLES_LIST, + PROJECT_DETAILS, UPCOMING_CYCLES_LIST, } from "constants/fetch-keys"; @@ -65,7 +66,20 @@ export const CreateUpdateCycleModal: React.FC = ({ } mutate(INCOMPLETE_CYCLES_LIST(projectId.toString())); mutate(CYCLES_LIST(projectId.toString())); - handleClose(); + + // update total cycles count in the project details + mutate( + PROJECT_DETAILS(projectId.toString()), + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + total_cycles: prevData.total_cycles + 1, + }; + }, + false + ); setToastAlert({ type: "success", @@ -121,8 +135,6 @@ export const CreateUpdateCycleModal: React.FC = ({ } } - handleClose(); - setToastAlert({ type: "success", title: "Success!", @@ -138,19 +150,16 @@ export const CreateUpdateCycleModal: React.FC = ({ }); }; - const dateChecker = async (payload: any) => { - try { - const res = await cycleService.cycleDateCheck( - workspaceSlug as string, - projectId as string, - payload - ); - console.log(res); - return res.status; - } catch (err) { - console.log(err); - return false; - } + const dateChecker = async (payload: CycleDateCheckData) => { + let status = false; + + await cycleService + .cycleDateCheck(workspaceSlug as string, projectId as string, payload) + .then((res) => { + status = res.status; + }); + + return status; }; const handleFormSubmit = async (formData: Partial) => { @@ -160,66 +169,34 @@ export const CreateUpdateCycleModal: React.FC = ({ ...formData, }; - if (payload.start_date && payload.end_date) { - if (!isDateGreaterThanToday(payload.end_date)) { - setToastAlert({ - type: "error", - title: "Error!", - message: "Unable to create cycle in past date. Please enter a valid date.", - }); - handleClose(); - return; - } + let isDateValid: boolean = true; - if (data?.start_date && data?.end_date) { - const isDateValidForExistingCycle = await dateChecker({ + if (payload.start_date && payload.end_date) { + if (data?.start_date && data?.end_date) + isDateValid = await dateChecker({ start_date: payload.start_date, end_date: payload.end_date, cycle_id: data.id, }); - - if (isDateValidForExistingCycle) { - await updateCycle(data.id, payload); - return; - } else { - setToastAlert({ - type: "error", - title: "Error!", - message: - "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates", - }); - handleClose(); - return; - } - } - - const isDateValid = await dateChecker({ - start_date: payload.start_date, - end_date: payload.end_date, - }); - - if (isDateValid) { - if (data) { - await updateCycle(data.id, payload); - } else { - await createCycle(payload); - } - } else { - setToastAlert({ - type: "error", - title: "Error!", - message: - "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates", + else + isDateValid = await dateChecker({ + start_date: payload.start_date, + end_date: payload.end_date, }); - handleClose(); - } - } else { - if (data) { - await updateCycle(data.id, payload); - } else { - await createCycle(payload); - } } + + if (isDateValid) { + if (data) await updateCycle(data.id, payload); + else await createCycle(payload); + + handleClose(); + } else + setToastAlert({ + type: "error", + title: "Error!", + message: + "You already have a cycle on the given dates, if you want to create a draft cycle, remove the dates.", + }); }; return ( diff --git a/apps/app/components/emoji-icon-picker/index.tsx b/apps/app/components/emoji-icon-picker/index.tsx index 61ba80843..ffe8b33d6 100644 --- a/apps/app/components/emoji-icon-picker/index.tsx +++ b/apps/app/components/emoji-icon-picker/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef } from "react"; +import React, { useEffect, useState } from "react"; // headless ui import { Tab, Transition, Popover } from "@headlessui/react"; // react colors @@ -11,8 +11,6 @@ import icons from "./icons.json"; // helpers import { getRecentEmojis, saveRecentEmoji } from "./helpers"; import { getRandomEmoji, renderEmoji } from "helpers/emoji.helper"; -// hooks -import useOutsideClickDetector from "hooks/use-outside-click-detector"; const tabOptions = [ { @@ -26,8 +24,6 @@ const tabOptions = [ ]; const EmojiIconPicker: React.FC = ({ label, value, onChange, onIconColorChange }) => { - const ref = useRef(null); - const [isOpen, setIsOpen] = useState(false); const [openColorPicker, setOpenColorPicker] = useState(false); const [activeColor, setActiveColor] = useState("rgb(var(--color-text-200))"); @@ -38,20 +34,13 @@ const EmojiIconPicker: React.FC = ({ label, value, onChange, onIconColorC setRecentEmojis(getRecentEmojis()); }, []); - useOutsideClickDetector(ref, () => { - setIsOpen(false); - }); - useEffect(() => { if (!value || value?.length === 0) onChange(getRandomEmoji()); }, [value, onChange]); return ( - - setIsOpen((prev) => !prev)} - > + + setIsOpen((prev) => !prev)} className="outline-none"> {label} { switch (stateGroup) { case "backlog": - return ; + return ( + + ); case "unstarted": - return ; + return ( + + ); case "started": - return ; + return ( + + ); case "completed": - return ; + return ( + + ); case "cancelled": - return ; + return ( + + ); default: return <>; } diff --git a/apps/app/components/inbox/filters-dropdown.tsx b/apps/app/components/inbox/filters-dropdown.tsx index 308f0bc64..7bb601949 100644 --- a/apps/app/components/inbox/filters-dropdown.tsx +++ b/apps/app/components/inbox/filters-dropdown.tsx @@ -37,37 +37,35 @@ export const FiltersDropdown: React.FC = () => { id: "priority", label: "Priority", value: PRIORITIES, - children: [ - ...PRIORITIES.map((priority) => ({ - id: priority === null ? "null" : priority, - label: ( -
- {getPriorityIcon(priority)} {priority ?? "None"} -
- ), - value: { - key: "priority", - value: priority === null ? "null" : priority, - }, - selected: filters?.priority?.includes(priority === null ? "null" : priority), - })), - ], + hasChildren: true, + children: PRIORITIES.map((priority) => ({ + id: priority === null ? "null" : priority, + label: ( +
+ {getPriorityIcon(priority)} {priority ?? "None"} +
+ ), + value: { + key: "priority", + value: priority === null ? "null" : priority, + }, + selected: filters?.priority?.includes(priority === null ? "null" : priority), + })), }, { id: "inbox_status", label: "Status", value: INBOX_STATUS.map((status) => status.value), - children: [ - ...INBOX_STATUS.map((status) => ({ - id: status.key, - label: status.label, - value: { - key: "inbox_status", - value: status.value, - }, - selected: filters?.inbox_status?.includes(status.value), - })), - ], + hasChildren: true, + children: INBOX_STATUS.map((status) => ({ + id: status.key, + label: status.label, + value: { + key: "inbox_status", + value: status.value, + }, + selected: filters?.inbox_status?.includes(status.value), + })), }, ]} /> diff --git a/apps/app/components/inbox/inbox-main-content.tsx b/apps/app/components/inbox/inbox-main-content.tsx index 49a3baf91..af1234859 100644 --- a/apps/app/components/inbox/inbox-main-content.tsx +++ b/apps/app/components/inbox/inbox-main-content.tsx @@ -19,6 +19,7 @@ import { IssueActivitySection, IssueDescriptionForm, IssueDetailsSidebar, + IssueReaction, } from "components/issues"; // ui import { Loader } from "components/ui"; @@ -303,6 +304,13 @@ export const InboxMainContent: React.FC = () => { } />
+ + +

Comments/Activity

diff --git a/apps/app/components/integration/jira/import-users.tsx b/apps/app/components/integration/jira/import-users.tsx index 3409e6d54..f73481dcd 100644 --- a/apps/app/components/integration/jira/import-users.tsx +++ b/apps/app/components/integration/jira/import-users.tsx @@ -30,7 +30,7 @@ export const JiraImportUsers: FC = () => { const router = useRouter(); const { workspaceSlug } = router.query; - const { workspaceMembers: members } = useWorkspaceMembers(workspaceSlug?.toString()); + const { workspaceMembers: members } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); const options = members?.map((member) => ({ value: member.member.email, diff --git a/apps/app/components/issues/activity.tsx b/apps/app/components/issues/activity.tsx index 617b35266..54dc39613 100644 --- a/apps/app/components/issues/activity.tsx +++ b/apps/app/components/issues/activity.tsx @@ -1,106 +1,23 @@ import React from "react"; +import Link from "next/link"; import { useRouter } from "next/router"; import useSWR from "swr"; // services import issuesService from "services/issues.service"; -// hooks -import useEstimateOption from "hooks/use-estimate-option"; // components +import { ActivityIcon, ActivityMessage } from "components/core"; import { CommentCard } from "components/issues/comment"; // ui import { Icon, Loader } from "components/ui"; -// icons -import { Squares2X2Icon } from "@heroicons/react/24/outline"; -import { BlockedIcon, BlockerIcon } from "components/icons"; // helpers -import { renderShortDateWithYearFormat, timeAgo } from "helpers/date-time.helper"; -import { addSpaceIfCamelCase } from "helpers/string.helper"; +import { timeAgo } from "helpers/date-time.helper"; // types -import { ICurrentUserResponse, IIssueComment, IIssueLabels } from "types"; +import { ICurrentUserResponse, IIssueComment } from "types"; // fetch-keys -import { PROJECT_ISSUES_ACTIVITY, PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; - -const activityDetails: { - [key: string]: { - message?: string; - icon: JSX.Element; - }; -} = { - assignee: { - message: "removed the assignee", - icon:
diff --git a/apps/app/components/issues/comment/comment-reaction.tsx b/apps/app/components/issues/comment/comment-reaction.tsx new file mode 100644 index 000000000..b6ce3bbbc --- /dev/null +++ b/apps/app/components/issues/comment/comment-reaction.tsx @@ -0,0 +1,92 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +// hooks +import useUser from "hooks/use-user"; +import useCommentReaction from "hooks/use-comment-reaction"; +// ui +import { ReactionSelector } from "components/core"; +// helper +import { renderEmoji } from "helpers/emoji.helper"; + +type Props = { + projectId?: string | string[]; + commentId: string; +}; + +export const CommentReaction: React.FC = (props) => { + const { projectId, commentId } = props; + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { user } = useUser(); + + const { + commentReactions, + groupedReactions, + handleReactionCreate, + handleReactionDelete, + isLoading, + } = useCommentReaction(workspaceSlug, projectId, commentId); + + const handleReactionClick = (reaction: string) => { + if (!workspaceSlug || !projectId || !commentId) return; + + const isSelected = commentReactions?.some( + (r) => r.actor === user?.id && r.reaction === reaction + ); + + if (isSelected) { + handleReactionDelete(reaction); + } else { + handleReactionCreate(reaction); + } + }; + + return ( +
+ reaction.actor === user?.id) + .map((r) => r.reaction) || [] + } + onSelect={handleReactionClick} + /> + + {Object.keys(groupedReactions || {}).map( + (reaction) => + groupedReactions?.[reaction]?.length && + groupedReactions[reaction].length > 0 && ( + + ) + )} +
+ ); +}; diff --git a/apps/app/components/issues/comment/index.ts b/apps/app/components/issues/comment/index.ts index cf13ca91e..61ac899ad 100644 --- a/apps/app/components/issues/comment/index.ts +++ b/apps/app/components/issues/comment/index.ts @@ -1,2 +1,3 @@ export * from "./add-comment"; export * from "./comment-card"; +export * from "./comment-reaction"; diff --git a/apps/app/components/issues/delete-issue-modal.tsx b/apps/app/components/issues/delete-issue-modal.tsx index a63d0f369..f46dae9aa 100644 --- a/apps/app/components/issues/delete-issue-modal.tsx +++ b/apps/app/components/issues/delete-issue-modal.tsx @@ -59,11 +59,12 @@ export const DeleteIssueModal: React.FC = ({ isOpen, handleClose, data, u }; const handleDeletion = async () => { + if (!workspaceSlug || !data) return; + setIsDeleteLoading(true); - if (!workspaceSlug || !projectId || !data) return; await issueServices - .deleteIssue(workspaceSlug as string, projectId as string, data.id, user) + .deleteIssue(workspaceSlug as string, data.project, data.id, user) .then(() => { if (issueView === "calendar") { const calendarFetchKey = cycleId @@ -72,7 +73,7 @@ export const DeleteIssueModal: React.FC = ({ isOpen, handleClose, data, u ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams) : viewId ? VIEW_ISSUES(viewId.toString(), calendarParams) - : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), calendarParams); + : PROJECT_ISSUES_LIST_WITH_PARAMS(data.project, calendarParams); mutate( calendarFetchKey, @@ -86,7 +87,7 @@ export const DeleteIssueModal: React.FC = ({ isOpen, handleClose, data, u ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams) : viewId ? VIEW_ISSUES(viewId.toString(), spreadsheetParams) - : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", spreadsheetParams); + : PROJECT_ISSUES_LIST_WITH_PARAMS(data.project, spreadsheetParams); if (data.parent) { mutate( SUB_ISSUES(data.parent.toString()), @@ -112,7 +113,7 @@ export const DeleteIssueModal: React.FC = ({ isOpen, handleClose, data, u } else { if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)); else if (moduleId) mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); - else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params)); + else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(data.project, params)); } handleClose(); diff --git a/apps/app/components/issues/description-form.tsx b/apps/app/components/issues/description-form.tsx index 0770834fa..e81c8c1b3 100644 --- a/apps/app/components/issues/description-form.tsx +++ b/apps/app/components/issues/description-form.tsx @@ -96,13 +96,7 @@ export const IssueDescriptionForm: FC = ({ setCharacterLimit(false); setIsSubmitting(true); - handleSubmit(handleDescriptionFormSubmit)() - .then(() => { - setIsSubmitting(false); - }) - .catch(() => { - setIsSubmitting(false); - }); + handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting(false)); }} required={true} className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary" @@ -110,7 +104,7 @@ export const IssueDescriptionForm: FC = ({ disabled={!isAllowed} /> {characterLimit && ( -
+
255 ? "text-red-500" : "" @@ -123,52 +117,47 @@ export const IssueDescriptionForm: FC = ({ )}
{errors.name ? errors.name.message : null} - { - if (!value && !watch("description_html")) return <>; +
+ { + if (!value && !watch("description_html")) return <>; - return ( - { - setShowAlert(true); - setValue("description", jsonValue); - }} - onHTMLChange={(htmlValue) => { - setShowAlert(true); - setValue("description_html", htmlValue); - }} - onBlur={() => { - setIsSubmitting(true); - handleSubmit(handleDescriptionFormSubmit)() - .then(() => { - setIsSubmitting(false); - setShowAlert(false); - }) - .catch(() => { - setIsSubmitting(false); - }); - }} - placeholder="Description" - editable={isAllowed} - /> - ); - }} - /> -
- Saving... + return ( + { + setShowAlert(true); + setValue("description", jsonValue); + }} + onHTMLChange={(htmlValue) => { + setShowAlert(true); + setValue("description_html", htmlValue); + }} + onBlur={() => { + setIsSubmitting(true); + handleSubmit(handleDescriptionFormSubmit)() + .then(() => setShowAlert(false)) + .finally(() => setIsSubmitting(false)); + }} + placeholder="Description" + editable={isAllowed} + /> + ); + }} + /> + {isSubmitting && ( +
+ Saving... +
+ )}
); diff --git a/apps/app/components/issues/form.tsx b/apps/app/components/issues/form.tsx index 02efa8f88..e6fc11ae2 100644 --- a/apps/app/components/issues/form.tsx +++ b/apps/app/components/issues/form.tsx @@ -75,6 +75,7 @@ const defaultValues: Partial = { assignees_list: [], labels: [], labels_list: [], + target_date: null, }; export interface IssueFormProps { @@ -201,18 +202,21 @@ export const IssueForm: FC = ({ else handleAiAssistance(res.response_html); }) .catch((err) => { + const error = err?.data?.error; + if (err.status === 429) setToastAlert({ type: "error", title: "Error!", message: + error || "You have reached the maximum number of requests of 50 requests per month per user.", }); else setToastAlert({ type: "error", title: "Error!", - message: "Some error occurred. Please try again.", + message: error || "Some error occurred. Please try again.", }); }) .finally(() => setIAmFeelingLucky(false)); @@ -224,9 +228,16 @@ export const IssueForm: FC = ({ reset({ ...defaultValues, ...initialData, + }); + }, [setFocus, initialData, reset]); + + // update projectId in form when projectId changes + useEffect(() => { + reset({ + ...getValues(), project: projectId, }); - }, [setFocus, initialData, reset, projectId]); + }, [getValues, projectId, reset]); return ( <> @@ -260,8 +271,10 @@ export const IssueForm: FC = ({ render={({ field: { value, onChange } }) => ( { + onChange(val); + setActiveProject(val); + }} /> )} /> @@ -271,7 +284,6 @@ export const IssueForm: FC = ({
{watch("parent") && - watch("parent") !== "" && (fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && selectedParentIssue && (
@@ -476,7 +488,7 @@ export const IssueForm: FC = ({ )} {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( - {watch("parent") && watch("parent") !== "" ? ( + {watch("parent") ? ( <> = (props) => { + const { workspaceSlug, projectId, issueId } = props; + + const { user } = useUserAuth(); + + const { reactions, groupedReactions, handleReactionCreate, handleReactionDelete } = + useIssueReaction(workspaceSlug, projectId, issueId); + + const handleReactionClick = (reaction: string) => { + if (!workspaceSlug || !projectId || !issueId) return; + + const isSelected = reactions?.some((r) => r.actor === user?.id && r.reaction === reaction); + + if (isSelected) { + handleReactionDelete(reaction); + } else { + handleReactionCreate(reaction); + } + }; + + return ( +
+ reaction.actor === user?.id).map((r) => r.reaction) || [] + } + onSelect={handleReactionClick} + /> + + {Object.keys(groupedReactions || {}).map( + (reaction) => + groupedReactions?.[reaction]?.length && + groupedReactions[reaction].length > 0 && ( + + ) + )} +
+ ); +}; diff --git a/apps/app/components/issues/label.tsx b/apps/app/components/issues/label.tsx new file mode 100644 index 000000000..f3a7be9dd --- /dev/null +++ b/apps/app/components/issues/label.tsx @@ -0,0 +1,55 @@ +import React from "react"; + +// components +import { Tooltip } from "components/ui"; +// types +import { IIssue } from "types"; + +type Props = { + issue: IIssue; + maxRender?: number; +}; + +export const ViewIssueLabel: React.FC = ({ issue, maxRender = 1 }) => ( + <> + {issue.label_details.length > 0 ? ( + issue.label_details.length <= maxRender ? ( + <> + {issue.label_details.map((label, index) => ( +
+ +
+ + {label.name} +
+
+
+ ))} + + ) : ( +
+ l.name).join(", ")} + > +
+ + {`${issue.label_details.length} Labels`} +
+
+
+ ) + ) : ( + "" + )} + +); diff --git a/apps/app/components/issues/main-content.tsx b/apps/app/components/issues/main-content.tsx index 179df717a..316d39e8a 100644 --- a/apps/app/components/issues/main-content.tsx +++ b/apps/app/components/issues/main-content.tsx @@ -17,9 +17,13 @@ import { IssueAttachments, IssueDescriptionForm, SubIssuesList, + IssueReaction, } from "components/issues"; // ui import { CustomMenu } from "components/ui"; +// icons +import { LayerDiagonalIcon } from "components/icons"; +import { MinusCircleIcon } from "@heroicons/react/24/outline"; // types import { IIssue } from "types"; // fetch-keys @@ -53,58 +57,69 @@ export const IssueMainContent: React.FC = ({ ) : null ); + const siblingIssuesList = siblingIssues?.sub_issues.filter((i) => i.id !== issueDetails.id); return ( <>
- {issueDetails?.parent && issueDetails.parent !== "" ? ( -
- - - - - {issueDetails.project_detail.identifier}-{issueDetails.parent_detail?.sequence_id} - - + {issueDetails?.parent ? ( +
+ + +
+ + + {issueDetails.parent_detail?.project_detail.identifier}- + {issueDetails.parent_detail?.sequence_id} + +
+ {issueDetails.parent_detail?.name.substring(0, 50)}
- - {siblingIssues && siblingIssues.sub_issues.length > 0 ? ( - <> -

Sibling issues

- {siblingIssues.sub_issues.map((issue) => { - if (issue.id !== issueDetails.id) - return ( - - {issueDetails.project_detail.identifier}-{issue.sequence_id} - - ); - })} - - ) : ( -

- No sibling issues -

- )} + + {siblingIssuesList ? ( + siblingIssuesList.length > 0 ? ( + <> +

+ Sibling issues +

+ {siblingIssuesList.map((issue) => ( + + + {issueDetails.project_detail.identifier}-{issue.sequence_id} + + ))} + + ) : ( +

+ No sibling issues +

+ ) + ) : null} submitChanges({ parent: null })} + className="flex items-center gap-2 text-red-500 py-2" > - Remove parent issue + + Remove Parent Issue
@@ -114,6 +129,9 @@ export const IssueMainContent: React.FC = ({ handleFormSubmit={submitChanges} isAllowed={memberRole.isMember || memberRole.isOwner || !uneditable} /> + + +
diff --git a/apps/app/components/issues/modal.tsx b/apps/app/components/issues/modal.tsx index 09c4dbfe0..be3b556a1 100644 --- a/apps/app/components/issues/modal.tsx +++ b/apps/app/components/issues/modal.tsx @@ -18,6 +18,7 @@ import useToast from "hooks/use-toast"; import useInboxView from "hooks/use-inbox-view"; import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; import useProjects from "hooks/use-projects"; +import useMyIssues from "hooks/my-issues/use-my-issues"; // components import { IssueForm } from "components/issues"; // types @@ -25,7 +26,6 @@ import type { IIssue } from "types"; // fetch-keys import { PROJECT_ISSUES_DETAILS, - PROJECT_ISSUES_LIST, USER_ISSUE, SUB_ISSUES, PROJECT_ISSUES_LIST_WITH_PARAMS, @@ -40,11 +40,11 @@ import { import { INBOX_ISSUE_SOURCE } from "constants/inbox"; export interface IssuesModalProps { - isOpen: boolean; - handleClose: () => void; data?: IIssue | null; - prePopulateData?: Partial; + handleClose: () => void; + isOpen: boolean; isUpdatingSingleIssue?: boolean; + prePopulateData?: Partial; fieldsToShow?: ( | "project" | "name" @@ -58,15 +58,17 @@ export interface IssuesModalProps { | "parent" | "all" )[]; + onSubmit?: (data: Partial) => Promise; } export const CreateUpdateIssueModal: React.FC = ({ - isOpen, - handleClose, data, - prePopulateData, + handleClose, + isOpen, isUpdatingSingleIssue = false, + prePopulateData, fieldsToShow = ["all"], + onSubmit, }) => { // states const [createMore, setCreateMore] = useState(false); @@ -84,6 +86,8 @@ export const CreateUpdateIssueModal: React.FC = ({ const { user } = useUser(); const { projects } = useProjects(); + const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); + const { setToastAlert } = useToast(); if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string }; @@ -95,9 +99,14 @@ export const CreateUpdateIssueModal: React.FC = ({ }; useEffect(() => { - if (projects && projects.length > 0) + if (data && data.project) { + setActiveProject(data.project); + return; + } + + if (projects && projects.length > 0 && !activeProject) setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null); - }, [projectId, projects]); + }, [activeProject, data, projectId, projects]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -237,6 +246,7 @@ export const CreateUpdateIssueModal: React.FC = ({ if (issueView === "calendar") mutate(calendarFetchKey); if (issueView === "gantt_chart") mutate(ganttFetchKey); if (issueView === "spreadsheet") mutate(spreadsheetFetchKey); + if (groupedIssues) mutateMyIssues(); setToastAlert({ type: "success", @@ -306,6 +316,8 @@ export const CreateUpdateIssueModal: React.FC = ({ if (!data) await createIssue(payload); else await updateIssue(payload); + + if (onSubmit) await onSubmit(payload); }; if (!projects || projects.length === 0) return null; diff --git a/apps/app/components/issues/my-issues-list-item.tsx b/apps/app/components/issues/my-issues-list-item.tsx deleted file mode 100644 index e4eb4f82a..000000000 --- a/apps/app/components/issues/my-issues-list-item.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import React, { useCallback } from "react"; - -import Link from "next/link"; -import { useRouter } from "next/router"; - -import { mutate } from "swr"; - -// hooks -import useToast from "hooks/use-toast"; -import useUserAuth from "hooks/use-user-auth"; -// services -import issuesService from "services/issues.service"; -// components -import { - ViewDueDateSelect, - ViewPrioritySelect, - ViewStateSelect, -} from "components/issues/view-select"; -// icon -import { LinkIcon, PaperClipIcon } from "@heroicons/react/24/outline"; -import { LayerDiagonalIcon } from "components/icons"; -// ui -import { AssigneesList } from "components/ui/avatar"; -import { CustomMenu, Tooltip } from "components/ui"; -// types -import { IIssue, Properties } from "types"; -// helper -import { copyTextToClipboard, truncateText } from "helpers/string.helper"; -// fetch-keys -import { USER_ISSUE } from "constants/fetch-keys"; - -type Props = { - issue: IIssue; - properties: Properties; - projectId: string; -}; - -export const MyIssuesListItem: React.FC = ({ issue, properties, projectId }) => { - const router = useRouter(); - const { workspaceSlug } = router.query; - - const { user } = useUserAuth(); - - const { setToastAlert } = useToast(); - - const partialUpdateIssue = useCallback( - (formData: Partial, issue: IIssue) => { - if (!workspaceSlug) return; - - mutate( - USER_ISSUE(workspaceSlug as string), - (prevData) => - prevData?.map((p) => { - if (p.id === issue.id) return { ...p, ...formData }; - - return p; - }), - false - ); - - issuesService - .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user) - .then((res) => { - mutate(USER_ISSUE(workspaceSlug as string)); - }) - .catch((error) => { - console.log(error); - }); - }, - [workspaceSlug, projectId, user] - ); - - const handleCopyText = () => { - const originURL = - typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - copyTextToClipboard( - `${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}` - ).then(() => { - setToastAlert({ - type: "success", - title: "Link Copied!", - message: "Issue link copied to clipboard.", - }); - }); - }; - - const isNotAllowed = false; - - return ( -
-
- - - {properties?.key && ( - - - {issue.project_detail?.identifier}-{issue.sequence_id} - - - )} - - - {truncateText(issue.name, 50)} - - - - - -
- {properties.priority && ( - - )} - {properties.state && ( - - )} - {properties.due_date && issue.target_date && ( - - )} - {properties.labels && issue.labels.length > 0 && ( -
- {issue.label_details.map((label) => ( - - - {label.name} - - ))} -
- )} - {properties.assignee && ( -
- 0 - ? issue.assignee_details - .map((assignee) => - assignee?.first_name !== "" ? assignee?.first_name : assignee?.email - ) - .join(", ") - : "No Assignee" - } - > -
- -
-
-
- )} - {properties.sub_issue_count && issue.sub_issues_count > 0 && ( -
- -
- - {issue.sub_issues_count} -
-
-
- )} - {properties.link && issue.link_count > 0 && ( -
- -
- - {issue.link_count} -
-
-
- )} - {properties.attachment_count && issue.attachment_count > 0 && ( -
- -
- - {issue.attachment_count} -
-
-
- )} - - - - - Copy issue link - - - -
-
-
- ); -}; diff --git a/apps/app/components/issues/my-issues/index.ts b/apps/app/components/issues/my-issues/index.ts new file mode 100644 index 000000000..65a063f4c --- /dev/null +++ b/apps/app/components/issues/my-issues/index.ts @@ -0,0 +1,3 @@ +export * from "./my-issues-select-filters"; +export * from "./my-issues-view-options"; +export * from "./my-issues-view"; diff --git a/apps/app/components/issues/my-issues/my-issues-select-filters.tsx b/apps/app/components/issues/my-issues/my-issues-select-filters.tsx new file mode 100644 index 000000000..e3d2cdff0 --- /dev/null +++ b/apps/app/components/issues/my-issues/my-issues-select-filters.tsx @@ -0,0 +1,168 @@ +import { useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// services +import issuesService from "services/issues.service"; +// components +import { DueDateFilterModal } from "components/core"; +// ui +import { MultiLevelDropdown } from "components/ui"; +// icons +import { getPriorityIcon, getStateGroupIcon } from "components/icons"; +// helpers +import { checkIfArraysHaveSameElements } from "helpers/array.helper"; +// types +import { IIssueFilterOptions, IQuery } from "types"; +// fetch-keys +import { WORKSPACE_LABELS } from "constants/fetch-keys"; +// constants +import { GROUP_CHOICES, PRIORITIES } from "constants/project"; +import { DUE_DATES } from "constants/due-dates"; + +type Props = { + filters: Partial | IQuery; + onSelect: (option: any) => void; + direction?: "left" | "right"; + height?: "sm" | "md" | "rg" | "lg"; +}; + +export const MyIssuesSelectFilters: React.FC = ({ + filters, + onSelect, + direction = "right", + height = "md", +}) => { + const [isDueDateFilterModalOpen, setIsDueDateFilterModalOpen] = useState(false); + const [fetchLabels, setFetchLabels] = useState(false); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { data: labels } = useSWR( + workspaceSlug && fetchLabels ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, + workspaceSlug && fetchLabels + ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) + : null + ); + + return ( + <> + {isDueDateFilterModalOpen && ( + setIsDueDateFilterModalOpen(false)} + /> + )} + ({ + id: priority === null ? "null" : priority, + label: ( +
+ {getPriorityIcon(priority)} {priority ?? "None"} +
+ ), + value: { + key: "priority", + value: priority === null ? "null" : priority, + }, + selected: filters?.priority?.includes(priority === null ? "null" : priority), + })), + ], + }, + { + id: "state_group", + label: "State groups", + value: GROUP_CHOICES, + hasChildren: true, + children: [ + ...Object.keys(GROUP_CHOICES).map((key) => ({ + id: key, + label: ( +
+ {getStateGroupIcon(key as any, "16", "16")}{" "} + {GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]} +
+ ), + value: { + key: "state_group", + value: key, + }, + selected: filters?.state?.includes(key), + })), + ], + }, + { + id: "labels", + label: "Labels", + onClick: () => setFetchLabels(true), + value: labels, + hasChildren: true, + children: labels?.map((label) => ({ + id: label.id, + label: ( +
+
+ {label.name} +
+ ), + value: { + key: "labels", + value: label.id, + }, + selected: filters?.labels?.includes(label.id), + })), + }, + { + id: "target_date", + label: "Due date", + value: DUE_DATES, + hasChildren: true, + children: [ + ...(DUE_DATES?.map((option) => ({ + id: option.name, + label: option.name, + value: { + key: "target_date", + value: option.value, + }, + selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], option.value), + })) ?? []), + { + id: "custom", + label: "Custom", + value: "custom", + element: ( + + ), + }, + ], + }, + ]} + /> + + ); +}; 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 new file mode 100644 index 000000000..35ed2295c --- /dev/null +++ b/apps/app/components/issues/my-issues/my-issues-view-options.tsx @@ -0,0 +1,293 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +// headless ui +import { Popover, Transition } from "@headlessui/react"; +// hooks +import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter"; +import useEstimateOption from "hooks/use-estimate-option"; +// components +import { MyIssuesSelectFilters } from "components/issues"; +// ui +import { CustomMenu, ToggleSwitch, Tooltip } from "components/ui"; +// icons +import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import { FormatListBulletedOutlined, GridViewOutlined } from "@mui/icons-material"; +// helpers +import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +import { checkIfArraysHaveSameElements } from "helpers/array.helper"; +// types +import { Properties, TIssueViewOptions } from "types"; +// constants +import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue"; + +const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [ + { + type: "list", + Icon: FormatListBulletedOutlined, + }, + { + type: "kanban", + Icon: GridViewOutlined, + }, +]; + +export const MyIssuesViewOptions: React.FC = () => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { + issueView, + setIssueView, + groupBy, + setGroupBy, + orderBy, + setOrderBy, + showEmptyGroups, + setShowEmptyGroups, + properties, + setProperty, + filters, + setFilters, + } = useMyIssuesFilters(workspaceSlug?.toString()); + + const { isEstimateActive } = useEstimateOption(); + + return ( +
+
+ {issueViewOptions.map((option) => ( + {replaceUnderscoreIfSnakeCase(option.type)} View + } + position="bottom" + > + + + ))} +
+ { + 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 + ), + }); + else + setFilters({ + [option.key]: [...((filters[key] ?? []) as any[]), option.value], + }); + } + }} + direction="left" + height="rg" + /> + + {({ open }) => ( + <> + + View + + + + +
+
+ {issueView !== "calendar" && issueView !== "spreadsheet" && ( + <> +
+

Group by

+
+ option.key === groupBy) + ?.name ?? "Select" + } + className="!w-full" + buttonClassName="w-full" + > + {GROUP_BY_OPTIONS.map((option) => { + if (issueView === "kanban" && option.key === null) return null; + if (option.key === "state" || option.key === "created_by") + return null; + + return ( + setGroupBy(option.key)} + > + {option.name} + + ); + })} + +
+
+
+

Order by

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

Issue type

+
+ option.key === filters?.type) + ?.name ?? "Select" + } + className="!w-full" + buttonClassName="w-full" + > + {FILTER_ISSUE_OPTIONS.map((option) => ( + + setFilters({ + type: option.key, + }) + } + > + {option.name} + + ))} + +
+
+ + {issueView !== "calendar" && issueView !== "spreadsheet" && ( + <> +
+

Show empty states

+
+ +
+
+ + )} +
+ +
+

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 === "created_on" || key === "updated_on") + ) + return null; + + return ( + + ); + })} +
+
+
+
+
+ + )} +
+
+ ); +}; diff --git a/apps/app/components/issues/my-issues/my-issues-view.tsx b/apps/app/components/issues/my-issues/my-issues-view.tsx new file mode 100644 index 000000000..a30c7092c --- /dev/null +++ b/apps/app/components/issues/my-issues/my-issues-view.tsx @@ -0,0 +1,300 @@ +import { useCallback, useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR, { mutate } from "swr"; + +// react-beautiful-dnd +import { DropResult } from "react-beautiful-dnd"; +// services +import issuesService from "services/issues.service"; +// hooks +import useMyIssues from "hooks/my-issues/use-my-issues"; +import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter"; +import useUserAuth from "hooks/use-user-auth"; +// components +import { AllViews, FiltersList } from "components/core"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +// helpers +import { orderArrayBy } from "helpers/array.helper"; +// types +import { IIssue, IIssueFilterOptions } from "types"; +// fetch-keys +import { USER_ISSUES, WORKSPACE_LABELS } from "constants/fetch-keys"; +import { PlusIcon } from "@heroicons/react/24/outline"; + +type Props = { + openIssuesListModal?: () => void; + disableUserActions?: false; +}; + +export const MyIssuesView: React.FC = ({ + openIssuesListModal, + disableUserActions = false, +}) => { + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // update issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + // trash box + const [trashBox, setTrashBox] = useState(false); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { user } = useUserAuth(); + + const { groupedIssues, mutateMyIssues, isEmpty, params } = useMyIssues(workspaceSlug?.toString()); + const { filters, setFilters, issueView, groupBy, orderBy, properties, showEmptyGroups } = + useMyIssuesFilters(workspaceSlug?.toString()); + + const { data: labels } = useSWR( + workspaceSlug && (filters?.labels ?? []).length > 0 + ? WORKSPACE_LABELS(workspaceSlug.toString()) + : null, + workspaceSlug && (filters?.labels ?? []).length > 0 + ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) + : null + ); + + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + + const handleOnDragEnd = useCallback( + async (result: DropResult) => { + setTrashBox(false); + + if (!result.destination || !workspaceSlug || !groupedIssues || groupBy !== "priority") return; + + const { source, destination } = result; + + if (source.droppableId === destination.droppableId) return; + + const draggedItem = groupedIssues[source.droppableId][source.index]; + + if (!draggedItem) return; + + if (destination.droppableId === "trashBox") handleDeleteIssue(draggedItem); + else { + const sourceGroup = source.droppableId; + const destinationGroup = destination.droppableId; + + draggedItem[groupBy] = destinationGroup; + + mutate<{ + [key: string]: IIssue[]; + }>( + USER_ISSUES(workspaceSlug.toString(), params), + (prevData) => { + if (!prevData) return prevData; + + const sourceGroupArray = [...groupedIssues[sourceGroup]]; + const destinationGroupArray = [...groupedIssues[destinationGroup]]; + + sourceGroupArray.splice(source.index, 1); + destinationGroupArray.splice(destination.index, 0, draggedItem); + + return { + ...prevData, + [sourceGroup]: orderArrayBy(sourceGroupArray, orderBy), + [destinationGroup]: orderArrayBy(destinationGroupArray, orderBy), + }; + }, + false + ); + + // patch request + issuesService + .patchIssue( + workspaceSlug as string, + draggedItem.project, + draggedItem.id, + { + priority: draggedItem.priority, + }, + user + ) + .catch(() => mutate(USER_ISSUES(workspaceSlug.toString(), params))); + } + }, + [groupBy, groupedIssues, handleDeleteIssue, orderBy, params, user, workspaceSlug] + ); + + const addIssueToGroup = useCallback( + (groupTitle: string) => { + setCreateIssueModal(true); + + let preloadedValue: string | string[] = groupTitle; + + if (groupBy === "labels") { + if (groupTitle === "None") preloadedValue = []; + else preloadedValue = [groupTitle]; + } + + if (groupBy) + setPreloadedData({ + [groupBy]: preloadedValue, + actionType: "createIssue", + }); + else setPreloadedData({ actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData, groupBy] + ); + + const addIssueToDate = useCallback( + (date: string) => { + setCreateIssueModal(true); + setPreloadedData({ + target_date: date, + actionType: "createIssue", + }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }, + [setEditIssueModal, setIssueToEdit] + ); + + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + + const filtersToDisplay = { ...filters, assignees: null, created_by: null }; + + const nullFilters = Object.keys(filtersToDisplay).filter( + (key) => filtersToDisplay[key as keyof IIssueFilterOptions] === null + ); + const areFiltersApplied = + Object.keys(filtersToDisplay).length > 0 && + nullFilters.length !== Object.keys(filtersToDisplay).length; + + return ( + <> + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + onSubmit={async () => { + mutateMyIssues(); + }} + /> + setEditIssueModal(false)} + data={issueToEdit} + onSubmit={async () => { + mutateMyIssues(); + }} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + user={user} + /> + {areFiltersApplied && ( + <> +
+ + setFilters({ + labels: null, + priority: null, + state_group: null, + target_date: null, + type: null, + }) + } + /> +
+ {
} + + )} + , + text: "New Issue", + onClick: () => { + const e = new KeyboardEvent("keydown", { + key: "c", + }); + document.dispatchEvent(e); + }, + }, + }} + handleOnDragEnd={handleOnDragEnd} + handleIssueAction={handleIssueAction} + openIssuesListModal={openIssuesListModal ? openIssuesListModal : null} + removeIssue={null} + trashBox={trashBox} + setTrashBox={setTrashBox} + viewProps={{ + groupByProperty: groupBy, + groupedIssues, + isEmpty, + issueView, + mutateIssues: mutateMyIssues, + orderBy, + params, + properties, + showEmptyGroups, + }} + /> + + ); +}; diff --git a/apps/app/components/issues/parent-issues-list-modal.tsx b/apps/app/components/issues/parent-issues-list-modal.tsx index d591bb896..62091511a 100644 --- a/apps/app/components/issues/parent-issues-list-modal.tsx +++ b/apps/app/components/issues/parent-issues-list-modal.tsx @@ -11,8 +11,9 @@ import useDebounce from "hooks/use-debounce"; // components import { LayerDiagonalIcon } from "components/icons"; // ui -import { Loader } from "components/ui"; +import { Loader, ToggleSwitch, Tooltip } from "components/ui"; // icons +import { LaunchOutlined } from "@mui/icons-material"; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; // types import { ISearchIssueResponse } from "types"; @@ -24,7 +25,6 @@ type Props = { onChange: (issue: ISearchIssueResponse) => void; projectId: string; issueId?: string; - customDisplay?: JSX.Element; }; export const ParentIssuesListModal: React.FC = ({ @@ -34,12 +34,11 @@ export const ParentIssuesListModal: React.FC = ({ onChange, projectId, issueId, - customDisplay, }) => { const [searchTerm, setSearchTerm] = useState(""); const [issues, setIssues] = useState([]); - const [isLoading, setIsLoading] = useState(false); const [isSearching, setIsSearching] = useState(false); + const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); const debouncedSearchTerm: string = useDebounce(searchTerm, 500); @@ -49,35 +48,24 @@ export const ParentIssuesListModal: React.FC = ({ const handleClose = () => { onClose(); setSearchTerm(""); + setIsWorkspaceLevel(false); }; useEffect(() => { - if (!workspaceSlug || !projectId) return; + if (!isOpen || !workspaceSlug || !projectId) return; - setIsLoading(true); + setIsSearching(true); - if (debouncedSearchTerm) { - setIsSearching(true); - - projectService - .projectIssuesSearch(workspaceSlug as string, projectId as string, { - search: debouncedSearchTerm, - parent: true, - issue_id: issueId, - }) - .then((res) => { - setIssues(res); - }) - .finally(() => { - setIsLoading(false); - setIsSearching(false); - }); - } else { - setIssues([]); - setIsLoading(false); - setIsSearching(false); - } - }, [debouncedSearchTerm, workspaceSlug, projectId, issueId]); + projectService + .projectIssuesSearch(workspaceSlug as string, projectId as string, { + search: debouncedSearchTerm, + parent: true, + issue_id: issueId, + workspace_search: isWorkspaceLevel, + }) + .then((res) => setIssues(res)) + .finally(() => setIsSearching(false)); + }, [debouncedSearchTerm, isOpen, issueId, isWorkspaceLevel, projectId, workspaceSlug]); return ( <> @@ -124,28 +112,49 @@ export const ParentIssuesListModal: React.FC = ({ aria-hidden="true" /> setSearchTerm(e.target.value)} displayValue={() => ""} />
- {customDisplay &&
{customDisplay}
} - - {debouncedSearchTerm !== "" && ( +
+ +
+ setIsWorkspaceLevel((prevData) => !prevData)} + label="Workspace level" + /> + +
+
+
+ + {searchTerm !== "" && (
Search results for{" "} {'"'} - {debouncedSearchTerm} + {searchTerm} {'"'} {" "} in project:
)} - {!isLoading && + {!isSearching && issues.length === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( @@ -161,7 +170,7 @@ export const ParentIssuesListModal: React.FC = ({
)} - {isLoading || isSearching ? ( + {isSearching ? ( @@ -175,12 +184,12 @@ export const ParentIssuesListModal: React.FC = ({ key={issue.id} value={issue} className={({ active, selected }) => - `flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${ + `group flex items-center justify-between gap-2 cursor-pointer select-none rounded-md px-3 py-2 text-custom-text-200 ${ active ? "bg-custom-background-80 text-custom-text-100" : "" } ${selected ? "text-custom-text-100" : ""}` } > - <> +
= ({ {issue.project__identifier}-{issue.sequence_id} {" "} {issue.name} - +
+ e.stopPropagation()} + > + + ))} diff --git a/apps/app/components/issues/select/assignee.tsx b/apps/app/components/issues/select/assignee.tsx index 6805c931e..47fe07c42 100644 --- a/apps/app/components/issues/select/assignee.tsx +++ b/apps/app/components/issues/select/assignee.tsx @@ -21,7 +21,6 @@ export const IssueAssigneeSelect: React.FC = ({ projectId, value = [], on const router = useRouter(); const { workspaceSlug } = router.query; - // fetching project members const { data: members } = useSWR( workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, workspaceSlug && projectId diff --git a/apps/app/components/issues/select/project.tsx b/apps/app/components/issues/select/project.tsx index 39fd12491..5d1c964e1 100644 --- a/apps/app/components/issues/select/project.tsx +++ b/apps/app/components/issues/select/project.tsx @@ -8,14 +8,9 @@ import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline"; export interface IssueProjectSelectProps { value: string; onChange: (value: string) => void; - setActiveProject: React.Dispatch>; } -export const IssueProjectSelect: React.FC = ({ - value, - onChange, - setActiveProject, -}) => { +export const IssueProjectSelect: React.FC = ({ value, onChange }) => { const { projects } = useProjects(); return ( @@ -29,10 +24,7 @@ export const IssueProjectSelect: React.FC = ({ } - onChange={(val: string) => { - onChange(val); - setActiveProject(val); - }} + onChange={(val: string) => onChange(val)} noChevron > {projects ? ( diff --git a/apps/app/components/issues/select/state.tsx b/apps/app/components/issues/select/state.tsx index 99c4140b2..d62daba76 100644 --- a/apps/app/components/issues/select/state.tsx +++ b/apps/app/components/issues/select/state.tsx @@ -34,7 +34,7 @@ export const IssueStateSelect: React.FC = ({ setIsOpen, value, onChange, ? () => stateService.getStates(workspaceSlug as string, projectId) : null ); - const states = getStatesList(stateGroups ?? {}); + const states = getStatesList(stateGroups); const options = states?.map((state) => ({ value: state.id, @@ -48,7 +48,7 @@ export const IssueStateSelect: React.FC = ({ setIsOpen, value, onChange, })); const selectedOption = states?.find((s) => s.id === value); - const currentDefaultState = states.find((s) => s.default); + const currentDefaultState = states?.find((s) => s.default); return ( = ({ const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); const { setToastAlert } = useToast(); - const { projectDetails } = useProjectDetails(); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; const handleClose = () => { setIsBlockedModalOpen(false); @@ -54,11 +51,16 @@ export const SidebarBlockedSelect: React.FC = ({ return; } - const selectedIssues: BlockeIssue[] = data.map((i) => ({ + const selectedIssues: { blocked_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({ blocked_issue_detail: { id: i.id, name: i.name, sequence_id: i.sequence_id, + project_detail: { + id: i.project_id, + identifier: i.project__identifier, + name: i.project__name, + }, }, })); @@ -80,6 +82,7 @@ export const SidebarBlockedSelect: React.FC = ({ handleClose={() => setIsBlockedModalOpen(false)} searchParams={{ blocker_blocked_by: true, issue_id: issueId }} handleOnSubmit={onSubmit} + workspaceLevelToggle />
@@ -94,14 +97,15 @@ export const SidebarBlockedSelect: React.FC = ({ key={issue.blocked_issue_detail?.id} className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20" > - - - - {`${projectDetails?.identifier}-${issue.blocked_issue_detail?.sequence_id}`} - - + + {`${issue.blocked_issue_detail?.project_detail.identifier}-${issue.blocked_issue_detail?.sequence_id}`} + + + + + + + + + + + + +
+
+
+ {snoozed || archived || readNotification ? ( + + ) : ( + + )} +
+ + ); +}; diff --git a/apps/app/components/notifications/notification-popover.tsx b/apps/app/components/notifications/notification-popover.tsx index 255d8af04..0e57c472c 100644 --- a/apps/app/components/notifications/notification-popover.tsx +++ b/apps/app/components/notifications/notification-popover.tsx @@ -1,19 +1,20 @@ import React, { Fragment } from "react"; -import { useRouter } from "next/router"; - // hooks import useTheme from "hooks/use-theme"; import { Popover, Transition } from "@headlessui/react"; // hooks -import useWorkspaceMembers from "hooks/use-workspace-members"; import useUserNotification from "hooks/use-user-notifications"; // components -import { Icon, Loader, EmptyState, Tooltip } from "components/ui"; -import { SnoozeNotificationModal, NotificationCard } from "components/notifications"; +import { Loader, EmptyState, Tooltip } from "components/ui"; +import { + SnoozeNotificationModal, + NotificationCard, + NotificationHeader, +} from "components/notifications"; // icons import { NotificationsOutlined } from "@mui/icons-material"; // images @@ -21,9 +22,6 @@ import emptyNotification from "public/empty-state/notification.svg"; // helpers import { getNumberCount } from "helpers/string.helper"; -// type -import type { NotificationType } from "types"; - export const NotificationPopover = () => { const { notifications, @@ -37,44 +35,22 @@ export const NotificationPopover = () => { setSelectedTab, setSnoozed, snoozed, - notificationsMutate, + notificationMutate, markNotificationArchivedStatus, markNotificationReadStatus, markSnoozeNotification, notificationCount, totalNotificationCount, + setSize, + isLoadingMore, + hasMore, + isRefreshing, + setFetchNotifications, } = useUserNotification(); - const router = useRouter(); - const { workspaceSlug } = router.query; - - const { isOwner, isMember } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); - // theme context const { collapsed: sidebarCollapse } = useTheme(); - const notificationTabs: Array<{ - label: string; - value: NotificationType; - unreadCount?: number; - }> = [ - { - label: "My Issues", - value: "assigned", - unreadCount: notificationCount?.my_issues, - }, - { - label: "Created by me", - value: "created", - unreadCount: notificationCount?.created_issues, - }, - { - label: "Subscribed", - value: "watching", - unreadCount: notificationCount?.watching_issues, - }, - ]; - return ( <> { ) || null } onSuccess={() => { - notificationsMutate(); setSelectedNotificationForSnooze(null); }} /> - {({ open: isActive, close: closePopover }) => ( - <> - - - {sidebarCollapse ? null : Notifications} - {totalNotificationCount && totalNotificationCount > 0 ? ( - - {getNumberCount(totalNotificationCount)} - - ) : null} - - - -
-

Notifications

-
- - - - - - - - - - - - - -
-
-
- {snoozed || archived || readNotification ? ( - - ) : ( - - )} -
- - {notifications ? ( - notifications.length > 0 ? ( -
- {notifications.map((notification) => ( - + ) : ( +
+ - ))} -
+
+ ) ) : ( -
- -
- ) - ) : ( - - - - - - - - )} -
-
- - )} + + + + + + + + )} + + + + ); + }}
); diff --git a/apps/app/components/onboarding/invite-members.tsx b/apps/app/components/onboarding/invite-members.tsx index 9bd2a82e8..fee1cb252 100644 --- a/apps/app/components/onboarding/invite-members.tsx +++ b/apps/app/components/onboarding/invite-members.tsx @@ -1,12 +1,9 @@ import React, { useEffect } from "react"; -import useSWR, { mutate } from "swr"; - // react-hook-form import { Controller, useFieldArray, useForm } from "react-hook-form"; // services import workspaceService from "services/workspace.service"; -import userService from "services/user.service"; // hooks import useToast from "hooks/use-toast"; // ui @@ -14,16 +11,15 @@ import { CustomSelect, Input, PrimaryButton, SecondaryButton } from "components/ // icons import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; // types -import { ICurrentUserResponse, IWorkspace, OnboardingSteps } from "types"; -// fetch-keys -import { CURRENT_USER, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys"; +import { ICurrentUserResponse, IWorkspace, TOnboardingSteps } from "types"; // constants import { ROLE } from "constants/workspace"; type Props = { - workspace: IWorkspace | undefined; + finishOnboarding: () => Promise; + stepChange: (steps: Partial) => Promise; user: ICurrentUserResponse | undefined; - stepChange: (steps: Partial) => Promise; + workspace: IWorkspace | undefined; }; type EmailRole = { @@ -35,7 +31,12 @@ type FormValues = { emails: EmailRole[]; }; -export const InviteMembers: React.FC = ({ workspace, user, stepChange }) => { +export const InviteMembers: React.FC = ({ + finishOnboarding, + stepChange, + user, + workspace, +}) => { const { setToastAlert } = useToast(); const { @@ -49,38 +50,14 @@ export const InviteMembers: React.FC = ({ workspace, user, stepChange }) name: "emails", }); - const { data: invitations } = useSWR(USER_WORKSPACE_INVITATIONS, () => - workspaceService.userWorkspaceInvitations() - ); - const nextStep = async () => { - if (!user || !invitations) return; - - const payload: Partial = { + const payload: Partial = { workspace_invite: true, + workspace_join: true, }; - // update onboarding status from this step if no invitations are present - if (invitations.length === 0) { - payload.workspace_join = true; - - mutate( - CURRENT_USER, - (prevData) => { - if (!prevData) return prevData; - - return { - ...prevData, - is_onboarded: true, - }; - }, - false - ); - - await userService.updateUserOnBoard({ userRole: user.role }, user); - } - await stepChange(payload); + await finishOnboarding(); }; const onSubmit = async (formData: FormValues) => { diff --git a/apps/app/components/onboarding/join-workspaces.tsx b/apps/app/components/onboarding/join-workspaces.tsx index 2441542d6..84b5bdfc9 100644 --- a/apps/app/components/onboarding/join-workspaces.tsx +++ b/apps/app/components/onboarding/join-workspaces.tsx @@ -4,7 +4,6 @@ import useSWR, { mutate } from "swr"; // services import workspaceService from "services/workspace.service"; -import userService from "services/user.service"; // hooks import useUser from "hooks/use-user"; // ui @@ -14,17 +13,23 @@ import { CheckCircleIcon } from "@heroicons/react/24/outline"; // helpers import { truncateText } from "helpers/string.helper"; // types -import { ICurrentUserResponse, IUser, IWorkspaceMemberInvitation, OnboardingSteps } from "types"; +import { IWorkspaceMemberInvitation, TOnboardingSteps } from "types"; // fetch-keys -import { CURRENT_USER, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys"; +import { USER_WORKSPACES, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys"; // constants import { ROLE } from "constants/workspace"; type Props = { - stepChange: (steps: Partial) => Promise; + finishOnboarding: () => Promise; + stepChange: (steps: Partial) => Promise; + updateLastWorkspace: () => Promise; }; -export const JoinWorkspaces: React.FC = ({ stepChange }) => { +export const JoinWorkspaces: React.FC = ({ + finishOnboarding, + stepChange, + updateLastWorkspace, +}) => { const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); const [invitationsRespond, setInvitationsRespond] = useState([]); @@ -47,25 +52,13 @@ export const JoinWorkspaces: React.FC = ({ stepChange }) => { } }; - // complete onboarding - const finishOnboarding = async () => { + const handleNextStep = async () => { if (!user) return; - mutate( - CURRENT_USER, - (prevData) => { - if (!prevData) return prevData; - - return { - ...prevData, - is_onboarded: true, - }; - }, - false - ); - - await userService.updateUserOnBoard({ userRole: user.role }, user); await stepChange({ workspace_join: true }); + + if (user.onboarding_step.workspace_create && user.onboarding_step.workspace_invite) + await finishOnboarding(); }; const submitInvitations = async () => { @@ -77,11 +70,12 @@ export const JoinWorkspaces: React.FC = ({ stepChange }) => { .joinWorkspaces({ invitations: invitationsRespond }) .then(async () => { await mutateInvitations(); - await finishOnboarding(); + await mutate(USER_WORKSPACES); + await updateLastWorkspace(); - setIsJoiningWorkspaces(false); + await handleNextStep(); }) - .catch(() => setIsJoiningWorkspaces(false)); + .finally(() => setIsJoiningWorkspaces(false)); }; return ( @@ -142,14 +136,15 @@ export const JoinWorkspaces: React.FC = ({ stepChange }) => { type="submit" size="md" onClick={submitInvitations} - disabled={isJoiningWorkspaces || invitationsRespond.length === 0} + disabled={invitationsRespond.length === 0} + loading={isJoiningWorkspaces} > Accept & Join Skip for now diff --git a/apps/app/components/onboarding/workspace.tsx b/apps/app/components/onboarding/workspace.tsx index b401585dd..8f7442432 100644 --- a/apps/app/components/onboarding/workspace.tsx +++ b/apps/app/components/onboarding/workspace.tsx @@ -3,17 +3,25 @@ import { useState } from "react"; // ui import { SecondaryButton } from "components/ui"; // types -import { ICurrentUserResponse, OnboardingSteps } from "types"; +import { ICurrentUserResponse, IWorkspace, TOnboardingSteps } from "types"; // constants import { CreateWorkspaceForm } from "components/workspace"; type Props = { - user: ICurrentUserResponse | undefined; + finishOnboarding: () => Promise; + stepChange: (steps: Partial) => Promise; updateLastWorkspace: () => Promise; - stepChange: (steps: Partial) => Promise; + user: ICurrentUserResponse | undefined; + workspaces: IWorkspace[] | undefined; }; -export const Workspace: React.FC = ({ user, updateLastWorkspace, stepChange }) => { +export const Workspace: React.FC = ({ + finishOnboarding, + stepChange, + updateLastWorkspace, + user, + workspaces, +}) => { const [defaultValues, setDefaultValues] = useState({ name: "", slug: "", @@ -23,12 +31,21 @@ export const Workspace: React.FC = ({ user, updateLastWorkspace, stepChan const completeStep = async () => { if (!user) return; - await stepChange({ + const payload: Partial = { workspace_create: true, - }); + }; + + await stepChange(payload); await updateLastWorkspace(); }; + const secondaryButtonAction = async () => { + if (workspaces && workspaces.length > 0) { + await stepChange({ workspace_create: true, workspace_invite: true, workspace_join: true }); + await finishOnboarding(); + } else await stepChange({ profile_complete: false, workspace_join: false }); + }; + return (

Create your workspace

@@ -43,9 +60,11 @@ export const Workspace: React.FC = ({ user, updateLastWorkspace, stepChan default: "Continue", }} secondaryButton={ - stepChange({ profile_complete: false })}> - Back - + workspaces ? ( + + {workspaces.length > 0 ? "Skip & continue" : "Back"} + + ) : undefined } />
diff --git a/apps/app/components/pages/create-block.tsx b/apps/app/components/pages/create-block.tsx index d6186dda5..dd3c63c26 100644 --- a/apps/app/components/pages/create-block.tsx +++ b/apps/app/components/pages/create-block.tsx @@ -96,16 +96,16 @@ export const CreateBlock: React.FC = ({ user }) => { return (
-
+