diff --git a/.gitignore b/.gitignore index 8bf25a3f3..7568602d3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,8 @@ node_modules # Production /build -dist +dist/ +out/ # Misc .DS_Store diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 57539f24c..2b64e22ef 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -75,13 +75,13 @@ class IssueCreateSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(read_only=True, source="project") workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") - assignees_list = serializers.ListField( + assignees = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), write_only=True, required=False, ) - labels_list = serializers.ListField( + labels = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), write_only=True, required=False, @@ -99,6 +99,12 @@ class IssueCreateSerializer(BaseSerializer): "updated_at", ] + def to_representation(self, instance): + data = super().to_representation(instance) + data['assignees'] = [str(assignee.id) for assignee in instance.assignees.all()] + data['labels'] = [str(label.id) for label in instance.labels.all()] + return data + def validate(self, data): if ( data.get("start_date", None) is not None @@ -109,8 +115,8 @@ class IssueCreateSerializer(BaseSerializer): return data def create(self, validated_data): - assignees = validated_data.pop("assignees_list", None) - labels = validated_data.pop("labels_list", None) + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) project_id = self.context["project_id"] workspace_id = self.context["workspace_id"] @@ -168,8 +174,8 @@ class IssueCreateSerializer(BaseSerializer): return issue def update(self, instance, validated_data): - assignees = validated_data.pop("assignees_list", None) - labels = validated_data.pop("labels_list", None) + assignees = validated_data.pop("assignees", None) + labels = validated_data.pop("labels", None) # Related models project_id = instance.project_id diff --git a/apiserver/plane/api/urls/issue.py b/apiserver/plane/api/urls/issue.py index b484fe113..f1ef7c176 100644 --- a/apiserver/plane/api/urls/issue.py +++ b/apiserver/plane/api/urls/issue.py @@ -17,7 +17,7 @@ from plane.api.views import ( IssueSubscriberViewSet, IssueReactionViewSet, CommentReactionViewSet, - IssuePropertyViewSet, + IssueUserDisplayPropertyEndpoint, IssueArchiveViewSet, IssueRelationViewSet, IssueDraftViewSet, @@ -235,28 +235,11 @@ urlpatterns = [ ## End Comment Reactions ## IssueProperty path( - "workspaces//projects//issue-properties/", - IssuePropertyViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-roadmap", + "workspaces//projects//issue-display-properties/", + IssueUserDisplayPropertyEndpoint.as_view(), + name="project-issue-display-properties", ), - path( - "workspaces//projects//issue-properties//", - IssuePropertyViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-roadmap", - ), - ## IssueProperty Ebd + ## IssueProperty End ## Issue Archives path( "workspaces//projects//archived-issues/", diff --git a/apiserver/plane/api/urls_deprecated.py b/apiserver/plane/api/urls_deprecated.py index 0dc1b3a08..c108257b3 100644 --- a/apiserver/plane/api/urls_deprecated.py +++ b/apiserver/plane/api/urls_deprecated.py @@ -82,7 +82,7 @@ from plane.api.views import ( BulkDeleteIssuesEndpoint, BulkImportIssuesEndpoint, ProjectUserViewsEndpoint, - IssuePropertyViewSet, + IssueUserDisplayPropertyEndpoint, LabelViewSet, SubIssuesEndpoint, IssueLinkViewSet, @@ -1008,26 +1008,9 @@ urlpatterns = [ ## End Comment Reactions ## IssueProperty path( - "workspaces//projects//issue-properties/", - IssuePropertyViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-roadmap", - ), - path( - "workspaces//projects//issue-properties//", - IssuePropertyViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-roadmap", + "workspaces//projects//issue-display-properties/", + IssueUserDisplayPropertyEndpoint.as_view(), + name="project-issue-display-properties", ), ## IssueProperty Ebd ## Issue Archives diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 8a974f868..e17550050 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -71,7 +71,7 @@ from .issue import ( WorkSpaceIssuesEndpoint, IssueActivityEndpoint, IssueCommentViewSet, - IssuePropertyViewSet, + IssueUserDisplayPropertyEndpoint, LabelViewSet, BulkDeleteIssuesEndpoint, UserWorkSpaceIssues, diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index 538c8e484..7ab660e81 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -84,6 +84,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): capture_exception(e) return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) + print(e) if settings.DEBUG else print("Server Error") capture_exception(e) return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -161,6 +162,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): if isinstance(e, KeyError): return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST) + print(e) if settings.DEBUG else print("Server Error") capture_exception(e) return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 7b14af4a2..cfab09801 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -579,6 +579,7 @@ class CycleIssueViewSet(BaseViewSet): ) ) + total_issues = issues.count() issues_data = IssueStateSerializer(issues, many=True).data if sub_group_by and sub_group_by == group_by: @@ -588,14 +589,14 @@ class CycleIssueViewSet(BaseViewSet): ) if group_by: + grouped_results = group_results(issues_data, group_by, sub_group_by) return Response( - group_results(issues_data, group_by, sub_group_by), + {"data": grouped_results, "total_issues": total_issues}, status=status.HTTP_200_OK, ) return Response( - issues_data, - status=status.HTTP_200_OK, + {"data": issues_data, "total_issues": total_issues}, status=status.HTTP_200_OK ) def create(self, request, slug, project_id, cycle_id): diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index be95c304e..56b79ea34 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -130,7 +130,7 @@ class IssueViewSet(BaseViewSet): queryset=IssueReaction.objects.select_related("actor"), ) ) - ) + ).distinct() @method_decorator(gzip_page) def list(self, request, slug, project_id): @@ -217,6 +217,7 @@ class IssueViewSet(BaseViewSet): else: issue_queryset = issue_queryset.order_by(order_by_param) + total_issues = issue_queryset.count() issues = IssueLiteSerializer(issue_queryset, many=True).data ## Grouping the results @@ -229,12 +230,16 @@ class IssueViewSet(BaseViewSet): ) if group_by: + grouped_results = group_results(issues, group_by, sub_group_by) return Response( - group_results(issues, group_by, sub_group_by), + {"data": grouped_results, "total_issues": total_issues}, status=status.HTTP_200_OK, ) - return Response(issues, status=status.HTTP_200_OK) + return Response( + {"data": issues, "total_issues": total_issues}, status=status.HTTP_200_OK + ) + def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) @@ -421,6 +426,7 @@ class UserWorkSpaceIssues(BaseAPIView): else: issue_queryset = issue_queryset.order_by(order_by_param) + total_issues = issue_queryset.count() issues = IssueLiteSerializer(issue_queryset, many=True).data ## Grouping the results @@ -433,12 +439,15 @@ class UserWorkSpaceIssues(BaseAPIView): ) if group_by: + grouped_results = group_results(issues, group_by, sub_group_by) return Response( - group_results(issues, group_by, sub_group_by), + {"data": grouped_results, "total_issues": total_issues}, status=status.HTTP_200_OK, ) - return Response(issues, status=status.HTTP_200_OK) + return Response( + {"data": issues, "total_issues": total_issues}, status=status.HTTP_200_OK + ) class WorkSpaceIssuesEndpoint(BaseAPIView): @@ -597,41 +606,12 @@ class IssueCommentViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) -class IssuePropertyViewSet(BaseViewSet): - serializer_class = IssuePropertySerializer - model = IssueProperty +class IssueUserDisplayPropertyEndpoint(BaseAPIView): permission_classes = [ - ProjectEntityPermission, + ProjectLitePermission, ] - filterset_fields = [] - - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), user=self.request.user - ) - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(user=self.request.user) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - ) - - def list(self, request, slug, project_id): - queryset = self.get_queryset() - serializer = IssuePropertySerializer(queryset, many=True) - return Response( - serializer.data[0] if len(serializer.data) > 0 else [], - status=status.HTTP_200_OK, - ) - - def create(self, request, slug, project_id): + def post(self, request, slug, project_id): issue_property, created = IssueProperty.objects.get_or_create( user=request.user, project_id=project_id, @@ -640,16 +620,20 @@ class IssuePropertyViewSet(BaseViewSet): if not created: issue_property.properties = request.data.get("properties", {}) issue_property.save() - - serializer = IssuePropertySerializer(issue_property) - return Response(serializer.data, status=status.HTTP_200_OK) - issue_property.properties = request.data.get("properties", {}) issue_property.save() serializer = IssuePropertySerializer(issue_property) return Response(serializer.data, status=status.HTTP_201_CREATED) + def get(self, request, slug, project_id): + issue_property, _ = IssueProperty.objects.get_or_create( + user=request.user, project_id=project_id + ) + serializer = IssuePropertySerializer(issue_property) + return Response(serializer.data, status=status.HTTP_200_OK) + + class LabelViewSet(BaseViewSet): serializer_class = LabelSerializer model = Label @@ -963,8 +947,8 @@ class IssueAttachmentEndpoint(BaseAPIView): issue_attachments = IssueAttachment.objects.filter( issue_id=issue_id, workspace__slug=slug, project_id=project_id ) - serilaizer = IssueAttachmentSerializer(issue_attachments, many=True) - return Response(serilaizer.data, status=status.HTTP_200_OK) + serializer = IssueAttachmentSerializer(issue_attachments, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) class IssueArchiveViewSet(BaseViewSet): @@ -1165,9 +1149,7 @@ class IssueSubscriberViewSet(BaseViewSet): def list(self, request, slug, project_id, issue_id): members = ( - ProjectMember.objects.filter( - workspace__slug=slug, project_id=project_id - ) + ProjectMember.objects.filter(workspace__slug=slug, project_id=project_id) .annotate( is_subscribed=Exists( IssueSubscriber.objects.filter( @@ -2169,14 +2151,21 @@ class IssueDraftViewSet(BaseViewSet): else: issue_queryset = issue_queryset.order_by(order_by_param) + total_issues = issue_queryset.count() 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) + grouped_results = group_results(issues, group_by) + return Response( + {"data": grouped_results, "total_issues": total_issues}, + status=status.HTTP_200_OK, + ) - return Response(issues, status=status.HTTP_200_OK) + return Response( + {"data": issues, "total_issues": total_issues}, status=status.HTTP_200_OK + ) def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index ba088ea9c..e5bda6b65 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -149,6 +149,9 @@ class ModuleViewSet(BaseViewSet): if serializer.is_valid(): serializer.save() + + module = Module.objects.get(pk=serializer.data["id"]) + serializer = ModuleSerializer(module) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -361,7 +364,7 @@ class ModuleIssueViewSet(BaseViewSet): .values("count") ) ) - + total_issues = issues.count() issues_data = IssueStateSerializer(issues, many=True).data if sub_group_by and sub_group_by == group_by: @@ -371,14 +374,14 @@ class ModuleIssueViewSet(BaseViewSet): ) if group_by: + grouped_results = group_results(issues_data, group_by, sub_group_by) return Response( - group_results(issues_data, group_by, sub_group_by), + {"data": grouped_results, "total_issues": total_issues}, status=status.HTTP_200_OK, ) return Response( - issues_data, - status=status.HTTP_200_OK, + {"data": issues_data, "total_issues": total_issues}, status=status.HTTP_200_OK ) def create(self, request, slug, project_id, module_id): diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 1058ac593..632a5bf53 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -69,6 +69,7 @@ from plane.db.models import ( ModuleMember, Inbox, ProjectDeployBoard, + IssueProperty, ) from plane.bgtasks.project_invitation_task import project_invitation @@ -201,6 +202,11 @@ class ProjectViewSet(BaseViewSet): project_member = ProjectMember.objects.create( project_id=serializer.data["id"], member=request.user, role=20 ) + # Also create the issue property for the user + _ = IssueProperty.objects.create( + project_id=serializer.data["id"], + user=request.user, + ) if serializer.data["project_lead"] is not None and str( serializer.data["project_lead"] @@ -210,6 +216,11 @@ class ProjectViewSet(BaseViewSet): member_id=serializer.data["project_lead"], role=20, ) + # Also create the issue property for the user + IssueProperty.objects.create( + project_id=serializer.data["id"], + user_id=serializer.data["project_lead"], + ) # Default states states = [ @@ -262,12 +273,9 @@ class ProjectViewSet(BaseViewSet): ] ) - data = serializer.data - # Additional fields of the member - data["sort_order"] = project_member.sort_order - data["member_role"] = project_member.role - data["is_member"] = True - return Response(data, status=status.HTTP_201_CREATED) + project = self.get_queryset().filter(pk=serializer.data["id"]).first() + serializer = ProjectListSerializer(project) + return Response(serializer.data, status=status.HTTP_201_CREATED) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST, @@ -317,6 +325,8 @@ class ProjectViewSet(BaseViewSet): color="#ff7700", ) + project = self.get_queryset().filter(pk=serializer.data["id"]).first() + serializer = ProjectListSerializer(project) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -393,6 +403,8 @@ class InviteProjectEndpoint(BaseAPIView): member=user, project_id=project_id, role=role ) + _ = IssueProperty.objects.create(user=user, project_id=project_id) + return Response( ProjectMemberSerializer(project_member).data, status=status.HTTP_200_OK ) @@ -428,6 +440,18 @@ class UserProjectInvitationsViewset(BaseViewSet): ] ) + IssueProperty.objects.bulk_create( + [ + ProjectMember( + project=invitation.project, + workspace=invitation.project.workspace, + user=request.user, + created_by=request.user, + ) + for invitation in project_invitations + ] + ) + # Delete joined project invites project_invitations.delete() @@ -560,6 +584,7 @@ class AddMemberToProjectEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) bulk_project_members = [] + bulk_issue_props = [] project_members = ( ProjectMember.objects.filter( @@ -574,7 +599,8 @@ class AddMemberToProjectEndpoint(BaseAPIView): sort_order = [ project_member.get("sort_order") for project_member in project_members - if str(project_member.get("member_id")) == str(member.get("member_id")) + if str(project_member.get("member_id")) + == str(member.get("member_id")) ] bulk_project_members.append( ProjectMember( @@ -585,6 +611,13 @@ class AddMemberToProjectEndpoint(BaseAPIView): sort_order=sort_order[0] - 10000 if len(sort_order) else 65535, ) ) + bulk_issue_props.append( + IssueProperty( + user_id=member.get("member_id"), + project_id=project_id, + workspace_id=project.workspace_id, + ) + ) project_members = ProjectMember.objects.bulk_create( bulk_project_members, @@ -592,7 +625,12 @@ class AddMemberToProjectEndpoint(BaseAPIView): ignore_conflicts=True, ) + _ = IssueProperty.objects.bulk_create( + bulk_issue_props, batch_size=10, ignore_conflicts=True + ) + serializer = ProjectMemberSerializer(project_members, many=True) + return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -614,6 +652,7 @@ class AddTeamToProjectEndpoint(BaseAPIView): workspace = Workspace.objects.get(slug=slug) project_members = [] + issue_props = [] for member in team_members: project_members.append( ProjectMember( @@ -623,11 +662,23 @@ class AddTeamToProjectEndpoint(BaseAPIView): created_by=request.user, ) ) + issue_props.append( + IssueProperty( + project_id=project_id, + user_id=member, + workspace=workspace, + created_by=request.user, + ) + ) ProjectMember.objects.bulk_create( project_members, batch_size=10, ignore_conflicts=True ) + _ = IssueProperty.objects.bulk_create( + issue_props, batch_size=10, ignore_conflicts=True + ) + serializer = ProjectMemberSerializer(project_members, many=True) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -743,6 +794,19 @@ class ProjectJoinEndpoint(BaseAPIView): ignore_conflicts=True, ) + IssueProperty.objects.bulk_create( + [ + IssueProperty( + project_id=project_id, + user=request.user, + workspace=workspace, + created_by=request.user, + ) + for project_id in project_ids + ], + ignore_conflicts=True, + ) + return Response( {"message": "Projects joined successfully"}, status=status.HTTP_201_CREATED, diff --git a/apiserver/plane/api/views/view.py b/apiserver/plane/api/views/view.py index 0e4b074c6..c549324a1 100644 --- a/apiserver/plane/api/views/view.py +++ b/apiserver/plane/api/views/view.py @@ -184,6 +184,7 @@ class GlobalViewIssuesViewSet(BaseViewSet): else: issue_queryset = issue_queryset.order_by(order_by_param) + total_issues = issue_queryset.count() issues = IssueLiteSerializer(issue_queryset, many=True).data ## Grouping the results @@ -196,11 +197,15 @@ class GlobalViewIssuesViewSet(BaseViewSet): ) if group_by: + grouped_results = group_results(issues, group_by, sub_group_by) return Response( - group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK + {"data": grouped_results, "total_issues": total_issues}, + status=status.HTTP_200_OK, ) - return Response(issues, status=status.HTTP_200_OK) + return Response( + {"data": issues, "total_issues": total_issues}, status=status.HTTP_200_OK + ) class IssueViewViewSet(BaseViewSet): diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 9aa0ebcd9..e92859f14 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -1223,14 +1223,21 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): else: issue_queryset = issue_queryset.order_by(order_by_param) + total_issues = issue_queryset.count() 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) + grouped_results = group_results(issues, group_by) + return Response( + {"data": grouped_results, "total_issues": total_issues}, + status=status.HTTP_200_OK, + ) - return Response(issues, status=status.HTTP_200_OK) + return Response( + {"data": issues, "total_issues": total_issues}, status=status.HTTP_200_OK + ) class WorkspaceLabelsEndpoint(BaseAPIView): diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index 757ef601b..20dc65e51 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -25,6 +25,7 @@ from plane.db.models import ( WorkspaceIntegration, Label, User, + IssueProperty, ) from .workspace_invitation_task import workspace_invitation from plane.bgtasks.user_welcome_task import send_welcome_slack @@ -103,6 +104,20 @@ def service_importer(service, importer_id): ignore_conflicts=True, ) + IssueProperty.objects.bulk_create( + [ + IssueProperty( + project_id=importer.project_id, + workspace_id=importer.workspace_id, + user=user, + created_by=importer.created_by, + ) + for user in workspace_users + ], + batch_size=100, + ignore_conflicts=True, + ) + # Check if sync config is on for github importers if service == "github" and importer.config.get("sync", False): name = importer.metadata.get("name", False) diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 1ad54e3a8..1dbaf8bc9 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -29,117 +29,32 @@ from plane.db.models import ( IssueComment, ) from plane.api.serializers import IssueActivitySerializer +from plane.bgtasks.notification_task import notifications -# Track Chnages in name +# Track Changes in name def track_name( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): if current_instance.get("name") != requested_data.get("name"): issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="updated", old_value=current_instance.get("name"), new_value=requested_data.get("name"), field="name", - project=project, - workspace=project.workspace, - comment=f"updated the name to {requested_data.get('name')}", - epoch=epoch, - ) - ) - - -# Track changes in parent issue -def track_parent( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch -): - if current_instance.get("parent") != requested_data.get("parent"): - if requested_data.get("parent") == None: - old_parent = Issue.objects.get(pk=current_instance.get("parent")) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}", - new_value=None, - field="parent", - project=project, - workspace=project.workspace, - comment=f"updated the parent issue to None", - old_identifier=old_parent.id, - new_identifier=None, - epoch=epoch, - ) - ) - else: - new_parent = Issue.objects.get(pk=requested_data.get("parent")) - old_parent = Issue.objects.filter(pk=current_instance.get("parent")).first() - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}" - if old_parent is not None - else None, - new_value=f"{new_parent.project.identifier}-{new_parent.sequence_id}", - field="parent", - project=project, - workspace=project.workspace, - comment=f"updated the parent issue to {new_parent.name}", - old_identifier=old_parent.id if old_parent is not None else None, - new_identifier=new_parent.id, - epoch=epoch, - ) - ) - - -# Track changes in priority -def track_priority( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch -): - if current_instance.get("priority") != requested_data.get("priority"): - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("priority"), - new_value=requested_data.get("priority"), - field="priority", - project=project, - workspace=project.workspace, - comment=f"updated the priority to {requested_data.get('priority')}", - epoch=epoch, - ) - ) - - -# Track chnages in state of the issue -def track_state( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch -): - if current_instance.get("state") != requested_data.get("state"): - new_state = State.objects.get(pk=requested_data.get("state", None)) - old_state = State.objects.get(pk=current_instance.get("state", None)) - - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=old_state.name, - new_value=new_state.name, - field="state", - project=project, - workspace=project.workspace, - comment=f"updated the state to {new_state.name}", - old_identifier=old_state.id, - new_identifier=new_state.id, + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated the name to", epoch=epoch, ) ) @@ -147,7 +62,14 @@ def track_state( # Track issue description def track_description( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): if current_instance.get("description_html") != requested_data.get( "description_html" @@ -160,7 +82,7 @@ def track_description( if ( last_activity is not None and last_activity.field == "description" - and actor.id == last_activity.actor_id + and actor_id == last_activity.actor_id ): last_activity.created_at = timezone.now() last_activity.save(update_fields=["created_at"]) @@ -168,257 +90,343 @@ def track_description( issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="updated", old_value=current_instance.get("description_html"), new_value=requested_data.get("description_html"), field="description", - project=project, - workspace=project.workspace, - comment=f"updated the description to {requested_data.get('description_html')}", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated the description to", epoch=epoch, ) ) +# Track changes in parent issue +def track_parent( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + if current_instance.get("parent") != requested_data.get("parent"): + old_parent = Issue.objects.filter(pk=current_instance.get("parent")).first() + new_parent = Issue.objects.filter(pk=requested_data.get("parent")).first() + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=f"{old_parent.project.identifier}-{old_parent.sequence_id}" + if old_parent is not None + else "", + new_value=f"{new_parent.project.identifier}-{new_parent.sequence_id}" + if new_parent is not None + else "", + field="parent", + project_id=project_id, + workspace=workspace_id, + comment=f"updated the parent issue to", + old_identifier=old_parent.id if old_parent is not None else None, + new_identifier=new_parent.id if new_parent is not None else None, + epoch=epoch, + ) + ) + + +# Track changes in priority +def track_priority( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + if current_instance.get("priority") != requested_data.get("priority"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=current_instance.get("priority"), + new_value=requested_data.get("priority"), + field="priority", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated the priority to", + epoch=epoch, + ) + ) + + +# Track changes in state of the issue +def track_state( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + if current_instance.get("state") != requested_data.get("state"): + new_state = State.objects.get(pk=requested_data.get("state", None)) + old_state = State.objects.get(pk=current_instance.get("state", None)) + + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=old_state.name, + new_value=new_state.name, + field="state", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated the state to", + old_identifier=old_state.id, + new_identifier=new_state.id, + epoch=epoch, + ) + ) + + # Track changes in issue target date def track_target_date( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): if current_instance.get("target_date") != requested_data.get("target_date"): - if requested_data.get("target_date") == None: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("target_date"), - new_value=requested_data.get("target_date"), - field="target_date", - project=project, - workspace=project.workspace, - comment=f"updated the target date to None", - epoch=epoch, - ) - ) - else: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("target_date"), - new_value=requested_data.get("target_date"), - field="target_date", - project=project, - workspace=project.workspace, - comment=f"updated the target date to {requested_data.get('target_date')}", - epoch=epoch, - ) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=current_instance.get("target_date") + if current_instance.get("target_date") is not None + else "", + new_value=requested_data.get("target_date") + if requested_data.get("target_date") is not None + else "", + field="target_date", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated the target date to", + epoch=epoch, ) + ) # Track changes in issue start date def track_start_date( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): if current_instance.get("start_date") != requested_data.get("start_date"): - if requested_data.get("start_date") == None: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("start_date"), - new_value=requested_data.get("start_date"), - field="start_date", - project=project, - workspace=project.workspace, - comment=f"updated the start date to None", - epoch=epoch, - ) - ) - else: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("start_date"), - new_value=requested_data.get("start_date"), - field="start_date", - project=project, - workspace=project.workspace, - comment=f"updated the start date to {requested_data.get('start_date')}", - epoch=epoch, - ) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=current_instance.get("start_date") + if current_instance.get("start_date") is not None + else "", + new_value=requested_data.get("start_date") + if requested_data.get("start_date") is not None + else "", + field="start_date", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated the start date to ", + epoch=epoch, ) + ) # Track changes in issue labels def track_labels( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): - # Label Addition - if len(requested_data.get("labels_list")) > len(current_instance.get("labels")): - for label in requested_data.get("labels_list"): - if label not in current_instance.get("labels"): - label = Label.objects.get(pk=label) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value="", - new_value=label.name, - field="labels", - project=project, - workspace=project.workspace, - comment=f"added label {label.name}", - new_identifier=label.id, - old_identifier=None, - epoch=epoch, - ) - ) + requested_labels = set([str(lab) for lab in requested_data.get("labels_list", [])]) + current_labels = set([str(lab) for lab in current_instance.get("labels", [])]) - # Label Removal - if len(requested_data.get("labels_list")) < len(current_instance.get("labels")): - for label in current_instance.get("labels"): - if label not in requested_data.get("labels_list"): - label = Label.objects.get(pk=label) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=label.name, - new_value="", - field="labels", - project=project, - workspace=project.workspace, - comment=f"removed label {label.name}", - old_identifier=label.id, - new_identifier=None, - epoch=epoch, - ) - ) + added_labels = requested_labels - current_labels + dropped_labels = current_labels - requested_labels + + # Set of newly added labels + for added_label in added_labels: + label = Label.objects.get(pk=added_label) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + project_id=project_id, + workspace_id=workspace_id, + verb="updated", + field="labels", + comment="added label ", + old_value="", + new_value=label.name, + new_identifier=label.id, + old_identifier=None, + epoch=epoch, + ) + ) + + # Set of dropped labels + for dropped_label in dropped_labels: + label = Label.objects.get(pk=dropped_label) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=label.name, + new_value="", + field="labels", + project_id=project_id, + workspace_id=workspace_id, + comment=f"removed label ", + old_identifier=label.id, + new_identifier=None, + epoch=epoch, + ) + ) # Track changes in issue assignees def track_assignees( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): - # Assignee Addition - if len(requested_data.get("assignees_list")) > len( - current_instance.get("assignees") - ): - for assignee in requested_data.get("assignees_list"): - if assignee not in current_instance.get("assignees"): - assignee = User.objects.get(pk=assignee) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value="", - new_value=assignee.display_name, - field="assignees", - project=project, - workspace=project.workspace, - comment=f"added assignee {assignee.display_name}", - new_identifier=assignee.id, - epoch=epoch, - ) - ) + requested_assignees = set( + [str(asg) for asg in requested_data.get("assignees_list", [])] + ) + current_assignees = set([str(asg) for asg in current_instance.get("assignees", [])]) - # Assignee Removal - if len(requested_data.get("assignees_list")) < len( - current_instance.get("assignees") - ): - for assignee in current_instance.get("assignees"): - if assignee not in requested_data.get("assignees_list"): - assignee = User.objects.get(pk=assignee) - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=assignee.display_name, - new_value="", - field="assignees", - project=project, - workspace=project.workspace, - comment=f"removed assignee {assignee.display_name}", - old_identifier=assignee.id, - epoch=epoch, - ) - ) + added_assignees = requested_assignees - current_assignees + dropped_assginees = current_assignees - requested_assignees + for added_asignee in added_assignees: + assignee = User.objects.get(pk=added_asignee) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value="", + new_value=assignee.display_name, + field="assignees", + project_id=project_id, + workspace_id=workspace_id, + comment=f"added assignee ", + new_identifier=assignee.id, + epoch=epoch, + ) + ) -def create_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch -): - issue_activities.append( - IssueActivity( + for dropped_assignee in dropped_assginees: + assignee = User.objects.get(pk=dropped_assignee) + issue_activities.append( issue_id=issue_id, - project=project, - workspace=project.workspace, - comment=f"created the issue", - verb="created", - actor=actor, + actor_id=actor_id, + verb="updated", + old_value=assignee.display_name, + new_value="", + field="assignees", + project_id=project_id, + workspace_id=workspace_id, + comment=f"removed assignee ", + old_identifier=assignee.id, epoch=epoch, ) - ) def track_estimate_points( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): if current_instance.get("estimate_point") != requested_data.get("estimate_point"): - if requested_data.get("estimate_point") == None: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("estimate_point"), - new_value=requested_data.get("estimate_point"), - field="estimate_point", - project=project, - workspace=project.workspace, - comment=f"updated the estimate point to None", - epoch=epoch, - ) - ) - else: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("estimate_point"), - new_value=requested_data.get("estimate_point"), - field="estimate_point", - project=project, - workspace=project.workspace, - comment=f"updated the estimate point to {requested_data.get('estimate_point')}", - epoch=epoch, - ) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="updated", + old_value=current_instance.get("estimate_point") + if current_instance.get("estimate_point") is not None + else "", + new_value=requested_data.get("estimate_point") + if requested_data.get("estimate_point") is not None + else "", + field="estimate_point", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated the estimate point to ", + epoch=epoch, ) + ) def track_archive_at( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): if requested_data.get("archived_at") is None: issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"has restored the issue", verb="updated", - actor=actor, + actor_id=actor_id, field="archived_at", old_value="archive", new_value="restore", @@ -429,11 +437,11 @@ def track_archive_at( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"Plane has archived the issue", verb="updated", - actor=actor, + actor_id=actor_id, field="archived_at", old_value=None, new_value="archive", @@ -443,24 +451,30 @@ def track_archive_at( def track_closed_to( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): if requested_data.get("closed_to") is not None: updated_state = State.objects.get( - pk=requested_data.get("closed_to"), project=project + pk=requested_data.get("closed_to"), project_id=project_id ) - issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="updated", old_value=None, new_value=updated_state.name, field="state", - project=project, - workspace=project.workspace, - comment=f"Plane updated the state to {updated_state.name}", + project_id=project_id, + workspace_id=workspace_id, + comment=f"Plane updated the state to ", old_identifier=None, new_identifier=updated_state.id, epoch=epoch, @@ -468,8 +482,38 @@ def track_closed_to( ) +def create_issue_activity( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + comment=f"created the issue", + verb="created", + actor_id=actor_id, + epoch=epoch, + ) + ) + + def update_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): ISSUE_ACTIVITY_MAPPER = { "name": track_name, @@ -495,26 +539,34 @@ def update_issue_activity( func = ISSUE_ACTIVITY_MAPPER.get(key, None) if func is not None: func( - requested_data, - current_instance, - issue_id, - project, - actor, - issue_activities, - epoch, + requested_data=requested_data, + current_instance=current_instance, + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + actor_id=actor_id, + issue_activities=issue_activities, + epoch=epoch, ) def delete_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): issue_activities.append( IssueActivity( - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"deleted the issue", verb="deleted", - actor=actor, + actor_id=actor_id, field="issue", epoch=epoch, ) @@ -522,7 +574,14 @@ def delete_issue_activity( def create_comment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -532,11 +591,11 @@ def create_comment_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"created a comment", verb="created", - actor=actor, + actor_id=actor_id, field="comment", new_value=requested_data.get("comment_html", ""), new_identifier=requested_data.get("id", None), @@ -547,7 +606,14 @@ def create_comment_activity( def update_comment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -558,11 +624,11 @@ def update_comment_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"updated a comment", verb="updated", - actor=actor, + actor_id=actor_id, field="comment", old_value=current_instance.get("comment_html", ""), old_identifier=current_instance.get("id"), @@ -575,16 +641,23 @@ def update_comment_activity( def delete_comment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"deleted the comment", verb="deleted", - actor=actor, + actor_id=actor_id, field="comment", epoch=epoch, ) @@ -592,7 +665,14 @@ def delete_comment_activity( def create_cycle_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -614,13 +694,13 @@ def create_cycle_issue_activity( issue_activities.append( IssueActivity( issue_id=updated_record.get("issue_id"), - actor=actor, + actor_id=actor_id, verb="updated", old_value=old_cycle.name, new_value=new_cycle.name, field="cycles", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}", old_identifier=old_cycle.id, new_identifier=new_cycle.id, @@ -636,13 +716,13 @@ def create_cycle_issue_activity( issue_activities.append( IssueActivity( issue_id=created_record.get("fields").get("issue"), - actor=actor, + actor_id=actor_id, verb="created", old_value="", new_value=cycle.name, field="cycles", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"added cycle {cycle.name}", new_identifier=cycle.id, epoch=epoch, @@ -651,7 +731,14 @@ def create_cycle_issue_activity( def delete_cycle_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -666,13 +753,13 @@ def delete_cycle_issue_activity( issue_activities.append( IssueActivity( issue_id=issue, - actor=actor, + actor_id=actor_id, verb="deleted", old_value=cycle.name if cycle is not None else "", new_value="", field="cycles", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"removed this issue from {cycle.name if cycle is not None else None}", old_identifier=cycle.id if cycle is not None else None, epoch=epoch, @@ -681,7 +768,14 @@ def delete_cycle_issue_activity( def create_module_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -703,14 +797,14 @@ def create_module_issue_activity( issue_activities.append( IssueActivity( issue_id=updated_record.get("issue_id"), - actor=actor, + actor_id=actor_id, verb="updated", old_value=old_module.name, new_value=new_module.name, field="modules", - project=project, - workspace=project.workspace, - comment=f"updated module from {old_module.name} to {new_module.name}", + project_id=project_id, + workspace_id=workspace_id, + comment=f"updated module to ", old_identifier=old_module.id, new_identifier=new_module.id, epoch=epoch, @@ -724,13 +818,13 @@ def create_module_issue_activity( issue_activities.append( IssueActivity( issue_id=created_record.get("fields").get("issue"), - actor=actor, + actor_id=actor_id, verb="created", old_value="", new_value=module.name, field="modules", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"added module {module.name}", new_identifier=module.id, epoch=epoch, @@ -739,7 +833,14 @@ def create_module_issue_activity( def delete_module_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -754,14 +855,14 @@ def delete_module_issue_activity( issue_activities.append( IssueActivity( issue_id=issue, - actor=actor, + actor_id=actor_id, verb="deleted", old_value=module.name if module is not None else "", new_value="", field="modules", - project=project, - workspace=project.workspace, - comment=f"removed this issue from {module.name if module is not None else None}", + project_id=project_id, + workspace_id=workspace_id, + comment=f"removed this issue from ", old_identifier=module.id if module is not None else None, epoch=epoch, ) @@ -769,7 +870,14 @@ def delete_module_issue_activity( def create_link_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + actor_id, + workspace_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -779,11 +887,11 @@ def create_link_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"created a link", verb="created", - actor=actor, + actor_id=actor_id, field="link", new_value=requested_data.get("url", ""), new_identifier=requested_data.get("id", None), @@ -793,7 +901,14 @@ def create_link_activity( def update_link_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -804,11 +919,11 @@ def update_link_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"updated a link", verb="updated", - actor=actor, + actor_id=actor_id, field="link", old_value=current_instance.get("url", ""), old_identifier=current_instance.get("id"), @@ -820,7 +935,14 @@ def update_link_activity( def delete_link_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): current_instance = ( json.loads(current_instance) if current_instance is not None else None @@ -829,11 +951,11 @@ def delete_link_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"deleted the link", verb="deleted", - actor=actor, + actor_id=actor_id, field="link", old_value=current_instance.get("url", ""), new_value="", @@ -843,7 +965,14 @@ def delete_link_activity( def create_attachment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + actor_id, + workspace_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -853,11 +982,11 @@ def create_attachment_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"created an attachment", verb="created", - actor=actor, + actor_id=actor_id, field="attachment", new_value=current_instance.get("asset", ""), new_identifier=current_instance.get("id", None), @@ -867,16 +996,23 @@ def create_attachment_activity( def delete_attachment_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"deleted the attachment", verb="deleted", - actor=actor, + actor_id=actor_id, field="attachment", epoch=epoch, ) @@ -884,13 +1020,22 @@ def delete_attachment_activity( def create_issue_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("reaction") is not None: issue_reaction = ( IssueReaction.objects.filter( - reaction=requested_data.get("reaction"), project=project, actor=actor + reaction=requested_data.get("reaction"), + project_id=project_id, + actor_id=actor_id, ) .values_list("id", flat=True) .first() @@ -899,13 +1044,13 @@ def create_issue_reaction_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="created", old_value=None, new_value=requested_data.get("reaction"), field="reaction", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment="added the reaction", old_identifier=None, new_identifier=issue_reaction, @@ -915,7 +1060,14 @@ def create_issue_reaction_activity( def delete_issue_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): current_instance = ( json.loads(current_instance) if current_instance is not None else None @@ -924,13 +1076,13 @@ def delete_issue_reaction_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="deleted", old_value=current_instance.get("reaction"), new_value=None, field="reaction", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment="removed the reaction", old_identifier=current_instance.get("identifier"), new_identifier=None, @@ -940,18 +1092,27 @@ def delete_issue_reaction_activity( def create_comment_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("reaction") is not None: comment_reaction_id, comment_id = ( CommentReaction.objects.filter( - reaction=requested_data.get("reaction"), project=project, actor=actor + reaction=requested_data.get("reaction"), + project_id=project_id, + actor_id=actor_id, ) .values_list("id", "comment__id") .first() ) - comment = IssueComment.objects.get(pk=comment_id, project=project) + comment = IssueComment.objects.get(pk=comment_id, project_id=project_id) if ( comment is not None and comment_reaction_id is not None @@ -960,13 +1121,13 @@ def create_comment_reaction_activity( issue_activities.append( IssueActivity( issue_id=comment.issue_id, - actor=actor, + actor_id=actor_id, verb="created", old_value=None, new_value=requested_data.get("reaction"), field="reaction", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment="added the reaction", old_identifier=None, new_identifier=comment_reaction_id, @@ -976,7 +1137,14 @@ def create_comment_reaction_activity( def delete_comment_reaction_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): current_instance = ( json.loads(current_instance) if current_instance is not None else None @@ -984,7 +1152,7 @@ def delete_comment_reaction_activity( if current_instance and current_instance.get("reaction") is not None: issue_id = ( IssueComment.objects.filter( - pk=current_instance.get("comment_id"), project=project + pk=current_instance.get("comment_id"), project_id=project_id ) .values_list("issue_id", flat=True) .first() @@ -993,13 +1161,13 @@ def delete_comment_reaction_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="deleted", old_value=current_instance.get("reaction"), new_value=None, field="reaction", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment="removed the reaction", old_identifier=current_instance.get("identifier"), new_identifier=None, @@ -1009,20 +1177,27 @@ def delete_comment_reaction_activity( def create_issue_vote_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None if requested_data and requested_data.get("vote") is not None: issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="created", old_value=None, new_value=requested_data.get("vote"), field="vote", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment="added the vote", old_identifier=None, new_identifier=None, @@ -1032,7 +1207,14 @@ def create_issue_vote_activity( def delete_issue_vote_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): current_instance = ( json.loads(current_instance) if current_instance is not None else None @@ -1041,13 +1223,13 @@ def delete_issue_vote_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - actor=actor, + actor_id=actor_id, verb="deleted", old_value=current_instance.get("vote"), new_value=None, field="vote", - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment="removed the vote", old_identifier=current_instance.get("identifier"), new_identifier=None, @@ -1057,7 +1239,14 @@ def delete_issue_vote_activity( def create_issue_relation_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -1073,13 +1262,13 @@ def create_issue_relation_activity( issue_activities.append( IssueActivity( issue_id=issue_relation.get("related_issue"), - actor=actor, + actor_id=actor_id, verb="created", old_value="", - new_value=f"{project.identifier}-{issue.sequence_id}", + new_value=f"{issue.project.identifier}-{issue.sequence_id}", field=relation_type, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"added {relation_type} relation", old_identifier=issue_relation.get("issue"), ) @@ -1088,13 +1277,13 @@ def create_issue_relation_activity( issue_activities.append( IssueActivity( issue_id=issue_relation.get("issue"), - actor=actor, + actor_id=actor_id, verb="created", old_value="", - new_value=f"{project.identifier}-{issue.sequence_id}", + new_value=f"{issue.project.identifier}-{issue.sequence_id}", field=f'{issue_relation.get("relation_type")}', - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f'added {issue_relation.get("relation_type")} relation', old_identifier=issue_relation.get("related_issue"), epoch=epoch, @@ -1103,7 +1292,14 @@ def create_issue_relation_activity( def delete_issue_relation_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -1118,13 +1314,13 @@ def delete_issue_relation_activity( issue_activities.append( IssueActivity( issue_id=current_instance.get("related_issue"), - actor=actor, + actor_id=actor_id, verb="deleted", - old_value=f"{project.identifier}-{issue.sequence_id}", + old_value=f"{issue.project.identifier}-{issue.sequence_id}", new_value="", field=relation_type, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"deleted {relation_type} relation", old_identifier=current_instance.get("issue"), epoch=epoch, @@ -1134,13 +1330,13 @@ def delete_issue_relation_activity( issue_activities.append( IssueActivity( issue_id=current_instance.get("issue"), - actor=actor, + actor_id=actor_id, verb="deleted", - old_value=f"{project.identifier}-{issue.sequence_id}", + old_value=f"{issue.project.identifier}-{issue.sequence_id}", new_value="", field=f'{current_instance.get("relation_type")}', - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f'deleted {current_instance.get("relation_type")} relation', old_identifier=current_instance.get("related_issue"), epoch=epoch, @@ -1149,24 +1345,38 @@ def delete_issue_relation_activity( def create_draft_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"drafted the issue", field="draft", verb="created", - actor=actor, + actor_id=actor_id, epoch=epoch, ) ) def update_draft_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None current_instance = ( @@ -1179,11 +1389,11 @@ def update_draft_issue_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"created the issue", verb="updated", - actor=actor, + actor_id=actor_id, epoch=epoch, ) ) @@ -1191,28 +1401,35 @@ def update_draft_issue_activity( issue_activities.append( IssueActivity( issue_id=issue_id, - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"updated the draft issue", field="draft", verb="updated", - actor=actor, + actor_id=actor_id, epoch=epoch, ) ) def delete_draft_issue_activity( - requested_data, current_instance, issue_id, project, actor, issue_activities, epoch + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, ): issue_activities.append( IssueActivity( - project=project, - workspace=project.workspace, + project_id=project_id, + workspace_id=workspace_id, comment=f"deleted the draft issue", field="draft", verb="deleted", - actor=actor, + actor_id=actor_id, epoch=epoch, ) ) @@ -1231,44 +1448,18 @@ def issue_activity( subscriber=True, ): try: - issue_activities = [] - actor = User.objects.get(pk=actor_id) project = Project.objects.get(pk=project_id) + issue = Issue.objects.filter(pk=issue_id).first() + workspace_id = project.workspace_id - if type not in [ - "cycle.activity.created", - "cycle.activity.deleted", - "module.activity.created", - "module.activity.deleted", - "issue_reaction.activity.created", - "issue_reaction.activity.deleted", - "comment_reaction.activity.created", - "comment_reaction.activity.deleted", - "issue_vote.activity.created", - "issue_vote.activity.deleted", - "issue_draft.activity.created", - "issue_draft.activity.updated", - "issue_draft.activity.deleted", - ]: - issue = Issue.objects.filter(pk=issue_id).first() - - if issue is not None: - 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 - try: - _ = IssueSubscriber.objects.get_or_create( - issue_id=issue_id, subscriber=actor - ) - except Exception as e: - pass + if issue is not None: + try: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) + except Exception as e: + pass ACTIVITY_MAPPER = { "issue.activity.created": create_issue_activity, @@ -1302,13 +1493,14 @@ def issue_activity( func = ACTIVITY_MAPPER.get(type) if func is not None: func( - requested_data, - current_instance, - issue_id, - project, - actor, - issue_activities, - epoch, + requested_data=requested_data, + current_instance=current_instance, + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + actor_id=actor_id, + issue_activities=issue_activities, + epoch=epoch, ) # Save all the values to database @@ -1332,89 +1524,17 @@ def issue_activity( except Exception as e: capture_exception(e) - if type not in [ - "cycle.activity.created", - "cycle.activity.deleted", - "module.activity.created", - "module.activity.deleted", - "issue_reaction.activity.created", - "issue_reaction.activity.deleted", - "comment_reaction.activity.created", - "comment_reaction.activity.deleted", - "issue_vote.activity.created", - "issue_vote.activity.deleted", - "issue_draft.activity.created", - "issue_draft.activity.updated", - "issue_draft.activity.deleted", - ]: - # Create Notifications - bulk_notifications = [] - - issue_subscribers = list( - IssueSubscriber.objects.filter(project=project, issue_id=issue_id) - .exclude(subscriber_id=actor_id) - .values_list("subscriber", flat=True) - ) - - issue_assignees = list( - IssueAssignee.objects.filter(project=project, issue_id=issue_id) - .exclude(assignee_id=actor_id) - .values_list("assignee", flat=True) - ) - - issue_subscribers = issue_subscribers + issue_assignees - - issue = Issue.objects.filter(pk=issue_id).first() - - # Add bot filtering - if ( - issue is not None - and issue.created_by_id is not None - and not issue.created_by.is_bot - and str(issue.created_by_id) != str(actor_id) - ): - issue_subscribers = issue_subscribers + [issue.created_by_id] - - for subscriber in list(set(issue_subscribers)): - for issue_activity in issue_activities_created: - bulk_notifications.append( - Notification( - workspace=project.workspace, - sender="in_app:issue_activities", - triggered_by_id=actor_id, - receiver_id=subscriber, - entity_identifier=issue_id, - entity_name="issue", - project=project, - title=issue_activity.comment, - data={ - "issue": { - "id": str(issue_id), - "name": str(issue.name), - "identifier": str(issue.project.identifier), - "sequence_id": issue.sequence_id, - "state_name": issue.state.name, - "state_group": issue.state.group, - }, - "issue_activity": { - "id": str(issue_activity.id), - "verb": str(issue_activity.verb), - "field": str(issue_activity.field), - "actor": str(issue_activity.actor_id), - "new_value": str(issue_activity.new_value), - "old_value": str(issue_activity.old_value), - "issue_comment": str( - issue_activity.issue_comment.comment_stripped - if issue_activity.issue_comment is not None - else "" - ), - }, - }, - ) - ) - - # Bulk create notifications - Notification.objects.bulk_create(bulk_notifications, batch_size=100) + notifications.delay( + type=type, + issue_id=issue_id, + actor_id=actor_id, + project_id=project_id, + subscriber=subscriber, + issue_activities_created=json.dumps( + IssueActivitySerializer(issue_activities_created, many=True).data, + cls=DjangoJSONEncoder, + ), + ) return except Exception as e: diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py new file mode 100644 index 000000000..f290a38c0 --- /dev/null +++ b/apiserver/plane/bgtasks/notification_task.py @@ -0,0 +1,102 @@ +# Python imports +import json + +# Django imports +from django.utils import timezone + +# Module imports +from plane.db.models import IssueSubscriber, Project, IssueAssignee, Issue, Notification + +# Third Party imports +from celery import shared_task + + +@shared_task +def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created): + issue_activities_created = ( + json.loads(issue_activities_created) if issue_activities_created is not None else None + ) + if type not in [ + "cycle.activity.created", + "cycle.activity.deleted", + "module.activity.created", + "module.activity.deleted", + "issue_reaction.activity.created", + "issue_reaction.activity.deleted", + "comment_reaction.activity.created", + "comment_reaction.activity.deleted", + "issue_vote.activity.created", + "issue_vote.activity.deleted", + "issue_draft.activity.created", + "issue_draft.activity.updated", + "issue_draft.activity.deleted", + ]: + # Create Notifications + bulk_notifications = [] + issue_subscribers = list( + IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id) + .exclude(subscriber_id=actor_id) + .values_list("subscriber", flat=True) + ) + + issue_assignees = list( + IssueAssignee.objects.filter(project_id=project_id, issue_id=issue_id) + .exclude(assignee_id=actor_id) + .values_list("assignee", flat=True) + ) + + issue_subscribers = issue_subscribers + issue_assignees + + issue = Issue.objects.filter(pk=issue_id).first() + + if subscriber: + # add the user to issue subscriber + try: + _ = IssueSubscriber.objects.get_or_create( + issue_id=issue_id, subscriber_id=actor_id + ) + except Exception as e: + pass + + project = Project.objects.get(pk=project_id) + + for subscriber in list(set(issue_subscribers)): + for issue_activity in issue_activities_created: + bulk_notifications.append( + Notification( + workspace=project.workspace, + sender="in_app:issue_activities", + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + project=project, + title=issue_activity.get("comment"), + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str(issue.project.identifier), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str(issue_activity.get("id")), + "verb": str(issue_activity.get("verb")), + "field": str(issue_activity.get("field")), + "actor": str(issue_activity.get("actor_id")), + "new_value": str(issue_activity.get("new_value")), + "old_value": str(issue_activity.get("old_value")), + "issue_comment": str( + issue_activity.get("issue_comment").comment_stripped + if issue_activity.get("issue_comment") is not None + else "" + ), + }, + }, + ) + ) + + # Bulk create notifications + Notification.objects.bulk_create(bulk_notifications, batch_size=100) diff --git a/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py new file mode 100644 index 000000000..4890ec9d5 --- /dev/null +++ b/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.5 on 2023-10-18 12:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.issue + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0045_issueactivity_epoch_workspacemember_issue_props_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='issueproperty', + name='properties', + field=models.JSONField(default=plane.db.models.issue.get_default_properties), + ), + ] diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 3ba054d49..9ba73fd43 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -16,6 +16,24 @@ from . import ProjectBaseModel from plane.utils.html_processor import strip_tags +def get_default_properties(): + return { + "assignee": True, + "start_date": True, + "due_date": True, + "labels": True, + "key": True, + "priority": True, + "state": True, + "sub_issue_count": True, + "link": True, + "attachment_count": True, + "estimate": True, + "created_on": True, + "updated_on": True, + } + + # TODO: Handle identifiers for Bulk Inserts - nk class IssueManager(models.Manager): def get_queryset(self): @@ -39,7 +57,7 @@ class Issue(ProjectBaseModel): ("high", "High"), ("medium", "Medium"), ("low", "Low"), - ("none", "None") + ("none", "None"), ) parent = models.ForeignKey( "self", @@ -186,7 +204,7 @@ class IssueRelation(ProjectBaseModel): ("relates_to", "Relates To"), ("blocked_by", "Blocked By"), ) - + issue = models.ForeignKey( Issue, related_name="issue_relation", on_delete=models.CASCADE ) @@ -208,7 +226,7 @@ class IssueRelation(ProjectBaseModel): ordering = ("-created_at",) def __str__(self): - return f"{self.issue.name} {self.related_issue.name}" + return f"{self.issue.name} {self.related_issue.name}" class IssueAssignee(ProjectBaseModel): @@ -327,7 +345,9 @@ class IssueComment(ProjectBaseModel): comment_json = models.JSONField(blank=True, default=dict) comment_html = models.TextField(blank=True, default="

") attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) - issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_comments") + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_comments" + ) # System can also create comment actor = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -367,7 +387,7 @@ class IssueProperty(ProjectBaseModel): on_delete=models.CASCADE, related_name="issue_property_user", ) - properties = models.JSONField(default=dict) + properties = models.JSONField(default=get_default_properties) class Meta: verbose_name = "Issue Property" @@ -515,7 +535,10 @@ class IssueVote(ProjectBaseModel): ) class Meta: - unique_together = ["issue", "actor",] + unique_together = [ + "issue", + "actor", + ] verbose_name = "Issue Vote" verbose_name_plural = "Issue Votes" db_table = "issue_votes" diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 9c6bd95a9..541a0cfd4 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -14,19 +14,21 @@ from .common import * # noqa # Database DEBUG = int(os.environ.get("DEBUG", 0)) == 1 -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": "plane", - "USER": os.environ.get("PGUSER", ""), - "PASSWORD": os.environ.get("PGPASSWORD", ""), - "HOST": os.environ.get("PGHOST", ""), +if bool(os.environ.get("DATABASE_URL")): + # Parse database configuration from $DATABASE_URL + DATABASES["default"] = dj_database_url.config() +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB"), + "USER": os.environ.get("POSTGRES_USER"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD"), + "HOST": os.environ.get("POSTGRES_HOST"), + } } -} -# Parse database configuration from $DATABASE_URL -DATABASES["default"] = dj_database_url.config() SITE_ID = 1 # Set the variable true if running in docker environment @@ -278,4 +280,3 @@ SCOUT_NAME = "Plane" # Unsplash Access key UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") - diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 0b8f39e14..52c181622 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -1,10 +1,24 @@ import re +import uuid from datetime import timedelta from django.utils import timezone + # The date from pattern pattern = re.compile(r"\d+_(weeks|months)$") +# check the valid uuids +def filter_valid_uuids(uuid_list): + valid_uuids = [] + for uuid_str in uuid_list: + try: + uuid_obj = uuid.UUID(uuid_str) + valid_uuids.append(uuid_obj) + except ValueError: + # ignore the invalid uuids + pass + return valid_uuids + # Get the 2_weeks, 3_months def string_date_filter(filter, duration, subsequent, term, date_filter, offset): @@ -61,40 +75,41 @@ def date_filter(filter, date_term, queries): def filter_state(params, filter, method): if method == "GET": - states = params.get("state").split(",") + states = [item for item in params.get("state").split(",") if item != 'null'] + states = filter_valid_uuids(states) if len(states) and "" not in states: filter["state__in"] = states else: - if params.get("state", None) and len(params.get("state")): + if params.get("state", None) and len(params.get("state")) and params.get("state") != 'null': filter["state__in"] = params.get("state") return filter def filter_state_group(params, filter, method): if method == "GET": - state_group = params.get("state_group").split(",") + state_group = [item for item in params.get("state_group").split(",") if item != 'null'] 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")): + if params.get("state_group", None) and len(params.get("state_group")) and params.get("state_group") != 'null': 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(",") + estimate_points = [item for item in params.get("estimate_point").split(",") if item != 'null'] if len(estimate_points) and "" not in estimate_points: filter["estimate_point__in"] = estimate_points else: - if params.get("estimate_point", None) and len(params.get("estimate_point")): + if params.get("estimate_point", None) and len(params.get("estimate_point")) and params.get("estimate_point") != 'null': filter["estimate_point__in"] = params.get("estimate_point") return filter def filter_priority(params, filter, method): if method == "GET": - priorities = params.get("priority").split(",") + priorities = [item for item in params.get("priority").split(",") if item != 'null'] if len(priorities) and "" not in priorities: filter["priority__in"] = priorities return filter @@ -102,44 +117,48 @@ def filter_priority(params, filter, method): def filter_parent(params, filter, method): if method == "GET": - parents = params.get("parent").split(",") + parents = [item for item in params.get("parent").split(",") if item != 'null'] + parents = filter_valid_uuids(parents) if len(parents) and "" not in parents: filter["parent__in"] = parents else: - if params.get("parent", None) and len(params.get("parent")): + if params.get("parent", None) and len(params.get("parent")) and params.get("parent") != 'null': filter["parent__in"] = params.get("parent") return filter def filter_labels(params, filter, method): if method == "GET": - labels = params.get("labels").split(",") + labels = [item for item in params.get("labels").split(",") if item != 'null'] + labels = filter_valid_uuids(labels) if len(labels) and "" not in labels: filter["labels__in"] = labels else: - if params.get("labels", None) and len(params.get("labels")): + if params.get("labels", None) and len(params.get("labels")) and params.get("labels") != 'null': filter["labels__in"] = params.get("labels") return filter def filter_assignees(params, filter, method): if method == "GET": - assignees = params.get("assignees").split(",") + assignees = [item for item in params.get("assignees").split(",") if item != 'null'] + assignees = filter_valid_uuids(assignees) if len(assignees) and "" not in assignees: filter["assignees__in"] = assignees else: - if params.get("assignees", None) and len(params.get("assignees")): + if params.get("assignees", None) and len(params.get("assignees")) and params.get("assignees") != 'null': filter["assignees__in"] = params.get("assignees") return filter def filter_created_by(params, filter, method): if method == "GET": - created_bys = params.get("created_by").split(",") + created_bys = [item for item in params.get("created_by").split(",") if item != 'null'] + created_bys = filter_valid_uuids(created_bys) if len(created_bys) and "" not in created_bys: filter["created_by__in"] = created_bys else: - if params.get("created_by", None) and len(params.get("created_by")): + if params.get("created_by", None) and len(params.get("created_by")) and params.get("created_by") != 'null': filter["created_by__in"] = params.get("created_by") return filter @@ -219,44 +238,47 @@ def filter_issue_state_type(params, filter, method): def filter_project(params, filter, method): if method == "GET": - projects = params.get("project").split(",") + projects = [item for item in params.get("project").split(",") if item != 'null'] + projects = filter_valid_uuids(projects) if len(projects) and "" not in projects: filter["project__in"] = projects else: - if params.get("project", None) and len(params.get("project")): + if params.get("project", None) and len(params.get("project")) and params.get("project") != 'null': filter["project__in"] = params.get("project") return filter def filter_cycle(params, filter, method): if method == "GET": - cycles = params.get("cycle").split(",") + cycles = [item for item in params.get("cycle").split(",") if item != 'null'] + cycles = filter_valid_uuids(cycles) if len(cycles) and "" not in cycles: filter["issue_cycle__cycle_id__in"] = cycles else: - if params.get("cycle", None) and len(params.get("cycle")): + if params.get("cycle", None) and len(params.get("cycle")) and params.get("cycle") != 'null': filter["issue_cycle__cycle_id__in"] = params.get("cycle") return filter def filter_module(params, filter, method): if method == "GET": - modules = params.get("module").split(",") + modules = [item for item in params.get("module").split(",") if item != 'null'] + modules = filter_valid_uuids(modules) if len(modules) and "" not in modules: filter["issue_module__module_id__in"] = modules else: - if params.get("module", None) and len(params.get("module")): + if params.get("module", None) and len(params.get("module")) and params.get("module") != 'null': filter["issue_module__module_id__in"] = params.get("module") return filter def filter_inbox_status(params, filter, method): if method == "GET": - status = params.get("inbox_status").split(",") + status = [item for item in params.get("inbox_status").split(",") if item != 'null'] if len(status) and "" not in status: filter["issue_inbox__status__in"] = status else: - if params.get("inbox_status", None) and len(params.get("inbox_status")): + if params.get("inbox_status", None) and len(params.get("inbox_status")) and params.get("inbox_status") != 'null': filter["issue_inbox__status__in"] = params.get("inbox_status") return filter @@ -275,11 +297,12 @@ def filter_sub_issue_toggle(params, filter, method): def filter_subscribed_issues(params, filter, method): if method == "GET": - subscribers = params.get("subscriber").split(",") + subscribers = [item for item in params.get("subscriber").split(",") if item != 'null'] + subscribers = filter_valid_uuids(subscribers) 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")): + if params.get("subscriber", None) and len(params.get("subscriber")) and params.get("subscriber") != 'null': filter["issue_subscribers__subscriber_id__in"] = params.get("subscriber") return filter diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index 78bf59db7..2c35ead1c 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -27,6 +27,7 @@ "next-themes": "^0.2.1" }, "dependencies": { + "react-moveable" : "^0.54.2", "@blueprintjs/popover2": "^2.0.10", "@tiptap/core": "^2.1.7", "@tiptap/extension-color": "^2.1.11", diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index 8a03462c0..b877dc7c0 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -213,7 +213,9 @@ module.exports = { }, }, }), - + screens: { + "3xl": "1792px", + }, // scale down font sizes to 90% of default fontSize: { xs: "0.675rem", diff --git a/packages/ui/package.json b/packages/ui/package.json index ab5f6c451..3a89a5c71 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -16,6 +16,7 @@ "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" }, "devDependencies": { + "@types/react-color" : "^3.0.9", "@types/node": "^20.5.2", "@types/react": "18.2.0", "@types/react-dom": "18.2.0", diff --git a/packages/ui/src/progress/circular-progress-indicator.tsx b/packages/ui/src/progress/circular-progress-indicator.tsx new file mode 100644 index 000000000..8ef74ea52 --- /dev/null +++ b/packages/ui/src/progress/circular-progress-indicator.tsx @@ -0,0 +1,102 @@ +import React, { Children } from "react"; + +interface ICircularProgressIndicator { + size: number; + percentage: number; + strokeWidth?: number; + strokeColor?: string; + children?: React.ReactNode; +} + +export const CircularProgressIndicator: React.FC = ( + props +) => { + const { size = 40, percentage = 25, strokeWidth = 6, children } = props; + + const sqSize = size; + const radius = (size - strokeWidth) / 2; + const viewBox = `0 0 ${sqSize} ${sqSize}`; + const dashArray = radius * Math.PI * 2; + const dashOffset = dashArray - (dashArray * percentage) / 100; + return ( +
+ + + + + + + + + + + + + + + + + + +
+ {children} +
+
+ ); +}; diff --git a/packages/ui/src/progress/index.tsx b/packages/ui/src/progress/index.tsx index ad5a371c1..56d28cee7 100644 --- a/packages/ui/src/progress/index.tsx +++ b/packages/ui/src/progress/index.tsx @@ -1,3 +1,4 @@ export * from "./radial-progress"; export * from "./progress-bar"; export * from "./linear-progress-indicator"; +export * from "./circular-progress-indicator"; diff --git a/space/next.config.js b/space/next.config.js index bd3749f10..7e9865784 100644 --- a/space/next.config.js +++ b/space/next.config.js @@ -8,6 +8,9 @@ const nextConfig = { experimental: { outputFileTracingRoot: path.join(__dirname, "../"), }, + images: { + unoptimized: true, + }, output: "standalone", }; diff --git a/space/package.json b/space/package.json index b2b064a53..5f3f60dc9 100644 --- a/space/package.json +++ b/space/package.json @@ -7,7 +7,8 @@ "develop": "next dev -p 4000", "build": "next build", "start": "next start -p 4000", - "lint": "next lint" + "lint": "next lint", + "export": "next export" }, "dependencies": { "@blueprintjs/core": "^4.16.3", @@ -16,6 +17,8 @@ "@emotion/styled": "^11.11.0", "@headlessui/react": "^1.7.13", "@mui/material": "^5.14.1", + "@plane/ui": "*", + "@plane/lite-text-editor": "*", "@plane/rich-text-editor": "*", "axios": "^1.3.4", "clsx": "^2.0.0", diff --git a/web/components/core/sidebar/links-list.tsx b/web/components/core/sidebar/links-list.tsx index 6de940296..ff8d30927 100644 --- a/web/components/core/sidebar/links-list.tsx +++ b/web/components/core/sidebar/links-list.tsx @@ -15,58 +15,63 @@ type Props = { export const LinksList: React.FC = ({ links, handleDeleteLink, handleEditLink, userAuth }) => { const isNotAllowed = userAuth.isGuest || userAuth.isViewer; - return ( <> {links.map((link) => ( -
- {!isNotAllowed && ( -
- - - - - +
+
+
+ + + + {link.title && link.title !== "" ? link.title : link.url}
- )} - -
- -
-
-
{link.title ?? link.url}
-

- Added {timeAgo(link.created_at)} -
- by{" "} - {link.created_by_detail.is_bot - ? link.created_by_detail.first_name + " Bot" - : link.created_by_detail.display_name} -

-
-
+ + {!isNotAllowed && ( +
+ + + + + +
+ )} +
+
+

+ Added {timeAgo(link.created_at)} +
+ by{" "} + {link.created_by_detail.is_bot + ? link.created_by_detail.first_name + " Bot" + : link.created_by_detail.display_name} +

+
))} diff --git a/web/components/core/sidebar/sidebar-progress-stats.tsx b/web/components/core/sidebar/sidebar-progress-stats.tsx index fed61b59f..bb6690172 100644 --- a/web/components/core/sidebar/sidebar-progress-stats.tsx +++ b/web/components/core/sidebar/sidebar-progress-stats.tsx @@ -1,11 +1,16 @@ import React from "react"; +import Image from "next/image"; // headless ui import { Tab } from "@headlessui/react"; // hooks import useLocalStorage from "hooks/use-local-storage"; import useIssuesView from "hooks/use-issues-view"; +// images +import emptyLabel from "public/empty-state/empty_label.svg"; +import emptyMembers from "public/empty-state/empty_members.svg"; // components +import { StateGroupIcon } from "@plane/ui"; import { SingleProgressStats } from "components/core"; // ui import { Avatar } from "components/ui"; @@ -17,9 +22,7 @@ import { TLabelsDistribution, TStateGroups, } from "types"; -// constants -import { STATE_GROUP_COLORS } from "constants/state"; -// types + type Props = { distribution: { assignees: TAssigneesDistribution[]; @@ -33,6 +36,7 @@ type Props = { module?: IModule; roundedTab?: boolean; noBackground?: boolean; + isPeekModuleDetails?: boolean; }; export const SidebarProgressStats: React.FC = ({ @@ -42,6 +46,7 @@ export const SidebarProgressStats: React.FC = ({ module, roundedTab, noBackground, + isPeekModuleDetails = false, }) => { const { filters, setFilters } = useIssuesView(); @@ -55,7 +60,6 @@ export const SidebarProgressStats: React.FC = ({ return 1; case "States": return 2; - default: return 0; } @@ -72,7 +76,6 @@ export const SidebarProgressStats: React.FC = ({ return setTab("Labels"); case 2: return setTab("States"); - default: return setTab("Assignees"); } @@ -82,15 +85,17 @@ export const SidebarProgressStats: React.FC = ({ as="div" className={`flex w-full items-center gap-2 justify-between rounded-md ${ noBackground ? "" : "bg-custom-background-90" - } px-1 py-1.5 - ${module ? "text-xs" : "text-sm"} `} + } p-0.5 + ${module ? "text-xs" : "text-sm"}`} > `w-full ${ roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" } px-3 py-1 text-custom-text-100 ${ - selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80" + selected + ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" + : "text-custom-text-400 hover:text-custom-text-300" }` } > @@ -101,7 +106,9 @@ export const SidebarProgressStats: React.FC = ({ `w-full ${ roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" } px-3 py-1 text-custom-text-100 ${ - selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80" + selected + ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" + : "text-custom-text-400 hover:text-custom-text-300" }` } > @@ -112,113 +119,128 @@ export const SidebarProgressStats: React.FC = ({ `w-full ${ roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded" } px-3 py-1 text-custom-text-100 ${ - selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80" + selected + ? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs" + : "text-custom-text-400 hover:text-custom-text-300" }` } > States - - - {distribution.assignees.map((assignee, index) => { - if (assignee.assignee_id) - return ( - - - {assignee.display_name} -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - onClick={() => { - if (filters?.assignees?.includes(assignee.assignee_id ?? "")) - setFilters({ - assignees: filters?.assignees?.filter((a) => a !== assignee.assignee_id), - }); - else - setFilters({ - assignees: [...(filters?.assignees ?? []), assignee.assignee_id ?? ""], - }); - }} - selected={filters?.assignees?.includes(assignee.assignee_id ?? "")} - /> - ); - else - return ( - -
- User + + {distribution.assignees.length > 0 ? ( + distribution.assignees.map((assignee, index) => { + if (assignee.assignee_id) + return ( + + + {assignee.display_name}
- No assignee -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - })} - - - {distribution.labels.map((label, index) => ( - - { + if (filters?.assignees?.includes(assignee.assignee_id ?? "")) + setFilters({ + assignees: filters?.assignees?.filter((a) => a !== assignee.assignee_id), + }); + else + setFilters({ + assignees: [...(filters?.assignees ?? []), assignee.assignee_id ?? ""], + }); + }, + selected: filters?.assignees?.includes(assignee.assignee_id ?? ""), + })} /> - {label.label_name ?? "No labels"} - - } - completed={label.completed_issues} - total={label.total_issues} - onClick={() => { - if (filters.labels?.includes(label.label_id ?? "")) - setFilters({ - labels: filters?.labels?.filter((l) => l !== label.label_id), - }); - else setFilters({ labels: [...(filters?.labels ?? []), label.label_id ?? ""] }); - }} - selected={filters?.labels?.includes(label.label_id ?? "")} - /> - ))} + ); + else + return ( + +
+ User +
+ No assignee + + } + completed={assignee.completed_issues} + total={assignee.total_issues} + /> + ); + }) + ) : ( +
+
+ empty members +
+
No assignees yet
+
+ )}
- + + {distribution.labels.length > 0 ? ( + distribution.labels.map((label, index) => ( + + + {label.label_name ?? "No labels"} + + } + completed={label.completed_issues} + total={label.total_issues} + {...(!isPeekModuleDetails && { + onClick: () => { + if (filters.labels?.includes(label.label_id ?? "")) + setFilters({ + labels: filters?.labels?.filter((l) => l !== label.label_id), + }); + else setFilters({ labels: [...(filters?.labels ?? []), label.label_id ?? ""] }); + }, + selected: filters?.labels?.includes(label.label_id ?? ""), + })} + /> + )) + ) : ( +
+
+ empty label +
+
No labels yet
+
+ )} +
+ {Object.keys(groupedIssues).map((group, index) => ( - + {group} } diff --git a/web/components/core/sidebar/single-progress-stats.tsx b/web/components/core/sidebar/single-progress-stats.tsx index 3ff214b57..ba6675c9a 100644 --- a/web/components/core/sidebar/single-progress-stats.tsx +++ b/web/components/core/sidebar/single-progress-stats.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { ProgressBar } from "@plane/ui"; +import { CircularProgressIndicator } from "@plane/ui"; type TSingleProgressStatsProps = { title: any; @@ -27,7 +27,7 @@ export const SingleProgressStats: React.FC = ({
- + {isNaN(Math.floor((completed / total) * 100)) ? "0" : Math.floor((completed / total) * 100)}% diff --git a/web/components/estimates/estimate-select.tsx b/web/components/estimates/estimate-select.tsx new file mode 100644 index 000000000..5ac283b83 --- /dev/null +++ b/web/components/estimates/estimate-select.tsx @@ -0,0 +1,160 @@ +import React, { useState } from "react"; +import { usePopper } from "react-popper"; +import { Combobox } from "@headlessui/react"; +import { Check, ChevronDown, Search, Triangle } from "lucide-react"; +// types +import { Tooltip } from "components/ui"; +import { Placement } from "@popperjs/core"; +// constants +import { IEstimatePoint } from "types"; + +type Props = { + value: number | null; + onChange: (value: number | null) => void; + estimatePoints: IEstimatePoint[] | undefined; + className?: string; + buttonClassName?: string; + optionsClassName?: string; + placement?: Placement; + hideDropdownArrow?: boolean; + disabled?: boolean; +}; + +export const EstimateSelect: React.FC = (props) => { + const { + value, + onChange, + estimatePoints, + className = "", + buttonClassName = "", + optionsClassName = "", + placement, + hideDropdownArrow = false, + disabled = false, + } = props; + + const [query, setQuery] = useState(""); + + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + const options: { value: number | null; query: string; content: any }[] | undefined = estimatePoints?.map( + (estimate) => ({ + value: estimate.key, + query: estimate.value, + content: ( +
+ + {estimate.value} +
+ ), + }) + ); + options?.unshift({ + value: null, + query: "none", + content: ( +
+ + None +
+ ), + }); + + const filteredOptions = + query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); + + const selectedEstimate = estimatePoints?.find((e) => e.key === value); + const label = ( + +
+ + {selectedEstimate?.value ?? "None"} +
+
+ ); + + return ( + onChange(val as number | null)} + disabled={disabled} + > + + + + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( + +

No matching results

+
+ ) + ) : ( +

Loading...

+ )} +
+
+
+
+ ); +}; diff --git a/web/components/estimates/index.tsx b/web/components/estimates/index.ts similarity index 77% rename from web/components/estimates/index.tsx rename to web/components/estimates/index.ts index f20c74780..b88ceaf03 100644 --- a/web/components/estimates/index.tsx +++ b/web/components/estimates/index.ts @@ -1,3 +1,4 @@ export * from "./create-update-estimate-modal"; -export * from "./single-estimate"; export * from "./delete-estimate-modal"; +export * from "./estimate-select"; +export * from "./single-estimate"; diff --git a/web/components/gantt-chart/chart/index.tsx b/web/components/gantt-chart/chart/index.tsx index bcee02ebc..82a7ed614 100644 --- a/web/components/gantt-chart/chart/index.tsx +++ b/web/components/gantt-chart/chart/index.tsx @@ -1,6 +1,4 @@ import { FC, useEffect, useState } from "react"; -// next -import { useRouter } from "next/router"; // icons // components import { GanttChartBlocks } from "components/gantt-chart"; @@ -13,7 +11,7 @@ import { MonthChartView } from "./month"; // import { QuarterChartView } from "./quarter"; // import { YearChartView } from "./year"; // icons -import { Expand, PlusIcon, Shrink } from "lucide-react"; +import { Expand, Shrink } from "lucide-react"; // views import { // generateHourChart, @@ -28,7 +26,6 @@ import { // getNumberOfDaysBetweenTwoDatesInYear, getMonthChartItemPositionWidthInMonth, } from "../views"; -// import { GanttInlineCreateIssueForm } from "components/core/views/gantt-chart-view/inline-create-issue-form"; // types import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types"; // data @@ -65,15 +62,9 @@ export const ChartViewRoot: FC = ({ enableReorder, bottomSpacing, }) => { - // router - const router = useRouter(); - const { cycleId, moduleId } = router.query; - const isCyclePage = router.pathname.split("/")[4] === "cycles" && !cycleId; - const isModulePage = router.pathname.split("/")[4] === "modules" && !moduleId; // states const [itemsContainerWidth, setItemsContainerWidth] = useState(0); const [fullScreenMode, setFullScreenMode] = useState(false); - const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false); const [chartBlocks, setChartBlocks] = useState(null); // blocks state management starts // hooks const { currentView, currentViewData, renderView, dispatch, allViews, updateScrollLeft } = useChart(); @@ -297,44 +288,6 @@ export const ChartViewRoot: FC = ({ SidebarBlockRender={SidebarBlockRender} enableReorder={enableReorder} /> - {chartBlocks && !(isCyclePage || isModulePage) && ( -
- {/* setIsCreateIssueFormOpen(false)} - onSuccess={() => { - const ganttSidebar = document.getElementById(`gantt-sidebar-${cycleId}`); - - const timeoutId = setTimeout(() => { - if (ganttSidebar) - ganttSidebar.scrollBy({ - top: ganttSidebar.scrollHeight, - left: 0, - behavior: "smooth", - }); - clearTimeout(timeoutId); - }, 10); - }} - prePopulatedData={{ - start_date: new Date(Date.now()).toISOString().split("T")[0], - target_date: new Date(Date.now() + 86400000).toISOString().split("T")[0], - ...(cycleId && { cycle: cycleId.toString() }), - ...(moduleId && { module: moduleId.toString() }), - }} - /> */} - - {!isCreateIssueFormOpen && ( - - )} -
- )}
void; + blocks: IGanttBlock[] | null; + SidebarBlockRender: React.FC; + enableReorder: boolean; +}; + +export const GanttSidebar: React.FC = (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder } = props; + + const router = useRouter(); + const { cycleId } = router.query; + + const { activeBlock, dispatch } = useChart(); + + // update the active block on hover + const updateActiveBlock = (block: IGanttBlock | null) => { + dispatch({ + type: "PARTIAL_UPDATE", + payload: { + activeBlock: block, + }, + }); + }; + + const handleOrderChange = (result: DropResult) => { + if (!blocks) return; + + const { source, destination } = result; + + // return if dropped outside the list + if (!destination) return; + + // return if dropped on the same index + if (source.index === destination.index) return; + + let updatedSortOrder = blocks[source.index].sort_order; + + // update the sort order to the lowest if dropped at the top + if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; + // update the sort order to the highest if dropped at the bottom + else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; + // update the sort order to the average of the two adjacent blocks if dropped in between + else { + const destinationSortingOrder = blocks[destination.index].sort_order; + const relativeDestinationSortingOrder = + source.index < destination.index + ? blocks[destination.index + 1].sort_order + : blocks[destination.index - 1].sort_order; + + updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; + } + + // extract the element from the source index and insert it at the destination index without updating the entire array + const removedElement = blocks.splice(source.index, 1)[0]; + blocks.splice(destination.index, 0, removedElement); + + // call the block update handler with the updated sort order, new and old index + blockUpdateHandler(removedElement.data, { + sort_order: { + destinationIndex: destination.index, + newSortOrder: updatedSortOrder, + sourceIndex: source.index, + }, + }); + }; + + return ( + + + {(droppableProvided) => ( +
+ <> + {blocks ? ( + blocks.map((block, index) => { + const duration = findTotalDaysInRange(block.start_date ?? "", block.target_date ?? "", true); + + return ( + + {(provided, snapshot) => ( +
updateActiveBlock(block)} + onMouseLeave={() => updateActiveBlock(null)} + ref={provided.innerRef} + {...provided.draggableProps} + > +
+ {enableReorder && ( + + )} +
+
+ +
+
+ {duration} day{duration > 1 ? "s" : ""} +
+
+
+
+ )} +
+ ); + }) + ) : ( + + + + + + + )} + {droppableProvided.placeholder} + +
+ )} +
+
+ ); +}; diff --git a/web/components/gantt-chart/module-sidebar.tsx b/web/components/gantt-chart/module-sidebar.tsx new file mode 100644 index 000000000..4b0b654f2 --- /dev/null +++ b/web/components/gantt-chart/module-sidebar.tsx @@ -0,0 +1,158 @@ +import { useRouter } from "next/router"; +import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd"; +import StrictModeDroppable from "components/dnd/StrictModeDroppable"; +import { MoreVertical } from "lucide-react"; +// hooks +import { useChart } from "./hooks"; +// ui +import { Loader } from "@plane/ui"; +// helpers +import { findTotalDaysInRange } from "helpers/date-time.helper"; +// types +import { IBlockUpdateData, IGanttBlock } from "./types"; + +type Props = { + title: string; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + blocks: IGanttBlock[] | null; + SidebarBlockRender: React.FC; + enableReorder: boolean; +}; + +export const GanttSidebar: React.FC = (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder } = props; + + const router = useRouter(); + const { cycleId } = router.query; + + const { activeBlock, dispatch } = useChart(); + + // update the active block on hover + const updateActiveBlock = (block: IGanttBlock | null) => { + dispatch({ + type: "PARTIAL_UPDATE", + payload: { + activeBlock: block, + }, + }); + }; + + const handleOrderChange = (result: DropResult) => { + if (!blocks) return; + + const { source, destination } = result; + + // return if dropped outside the list + if (!destination) return; + + // return if dropped on the same index + if (source.index === destination.index) return; + + let updatedSortOrder = blocks[source.index].sort_order; + + // update the sort order to the lowest if dropped at the top + if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; + // update the sort order to the highest if dropped at the bottom + else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; + // update the sort order to the average of the two adjacent blocks if dropped in between + else { + const destinationSortingOrder = blocks[destination.index].sort_order; + const relativeDestinationSortingOrder = + source.index < destination.index + ? blocks[destination.index + 1].sort_order + : blocks[destination.index - 1].sort_order; + + updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; + } + + // extract the element from the source index and insert it at the destination index without updating the entire array + const removedElement = blocks.splice(source.index, 1)[0]; + blocks.splice(destination.index, 0, removedElement); + + // call the block update handler with the updated sort order, new and old index + blockUpdateHandler(removedElement.data, { + sort_order: { + destinationIndex: destination.index, + newSortOrder: updatedSortOrder, + sourceIndex: source.index, + }, + }); + }; + + return ( + + + {(droppableProvided) => ( +
+ <> + {blocks ? ( + blocks.map((block, index) => { + const duration = findTotalDaysInRange(block.start_date ?? "", block.target_date ?? "", true); + + return ( + + {(provided, snapshot) => ( +
updateActiveBlock(block)} + onMouseLeave={() => updateActiveBlock(null)} + ref={provided.innerRef} + {...provided.draggableProps} + > +
+ {enableReorder && ( + + )} +
+
+ +
+
+ {duration} day{duration > 1 ? "s" : ""} +
+
+
+
+ )} +
+ ); + }) + ) : ( + + + + + + + )} + {droppableProvided.placeholder} + +
+ )} +
+
+ ); +}; diff --git a/web/components/gantt-chart/sidebar.tsx b/web/components/gantt-chart/sidebar.tsx index 4b0b654f2..0e7dae048 100644 --- a/web/components/gantt-chart/sidebar.tsx +++ b/web/components/gantt-chart/sidebar.tsx @@ -6,6 +6,8 @@ import { MoreVertical } from "lucide-react"; import { useChart } from "./hooks"; // ui import { Loader } from "@plane/ui"; +// components +import { GanttInlineCreateIssueForm } from "components/issues"; // helpers import { findTotalDaysInRange } from "helpers/date-time.helper"; // types @@ -17,11 +19,12 @@ type Props = { blocks: IGanttBlock[] | null; SidebarBlockRender: React.FC; enableReorder: boolean; + enableQuickIssueCreate?: boolean; }; export const GanttSidebar: React.FC = (props) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder } = props; + const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder, enableQuickIssueCreate } = props; const router = useRouter(); const { cycleId } = router.query; @@ -150,6 +153,7 @@ export const GanttSidebar: React.FC = (props) => { )} {droppableProvided.placeholder} +
)} diff --git a/web/components/headers/index.ts b/web/components/headers/index.ts index 0657b5ae3..1a52dd4de 100644 --- a/web/components/headers/index.ts +++ b/web/components/headers/index.ts @@ -18,3 +18,4 @@ export * from "./project-draft-issues"; export * from "./project-archived-issue-details"; export * from "./project-archived-issues"; export * from "./project-issue-details"; +export * from "./user-profile"; diff --git a/web/components/headers/modules-list.tsx b/web/components/headers/modules-list.tsx index 8d5df1b32..01c6df5c8 100644 --- a/web/components/headers/modules-list.tsx +++ b/web/components/headers/modules-list.tsx @@ -1,25 +1,28 @@ import { useRouter } from "next/router"; import Link from "next/link"; import { observer } from "mobx-react-lite"; -import { Plus } from "lucide-react"; +import { GanttChart, LayoutGrid, List, Plus } from "lucide-react"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // hooks import useLocalStorage from "hooks/use-local-storage"; // ui import { Breadcrumbs, BreadcrumbItem, Button, Tooltip } from "@plane/ui"; -import { Icon } from "components/ui"; // helper import { replaceUnderscoreIfSnakeCase, truncateText } from "helpers/string.helper"; -const moduleViewOptions: { type: "grid" | "gantt_chart"; icon: any }[] = [ +const moduleViewOptions: { type: "list" | "grid" | "gantt_chart"; icon: any }[] = [ { - type: "gantt_chart", - icon: "view_timeline", + type: "list", + icon: List, }, { type: "grid", - icon: "table_rows", + icon: LayoutGrid, + }, + { + type: "gantt_chart", + icon: GanttChart, }, ]; @@ -67,7 +70,7 @@ export const ModulesListHeader: React.FC = observer(() => { }`} onClick={() => setModulesView(option.type)} > - + ))} diff --git a/web/components/headers/user-profile.tsx b/web/components/headers/user-profile.tsx new file mode 100644 index 000000000..870aa1b54 --- /dev/null +++ b/web/components/headers/user-profile.tsx @@ -0,0 +1,25 @@ +import { FC } from "react"; + +import { useRouter } from "next/router"; + +// ui +import { BreadcrumbItem, Breadcrumbs } from "@plane/ui"; +// hooks +import { observer } from "mobx-react-lite"; + +export const UserProfileHeader: FC = observer(() => { + const router = useRouter(); + return ( +
+
+
+ router.back()}> + + +
+
+
+ ); +}); diff --git a/web/components/issues/issue-layouts/calendar/calendar.tsx b/web/components/issues/issue-layouts/calendar/calendar.tsx index 8b31c1eaf..9b7bfcaf9 100644 --- a/web/components/issues/issue-layouts/calendar/calendar.tsx +++ b/web/components/issues/issue-layouts/calendar/calendar.tsx @@ -44,12 +44,23 @@ export const CalendarChart: React.FC = observer((props) => {
{allWeeksOfActiveMonth && Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => ( - + ))}
)} {layout === "week" && ( - + )}
diff --git a/web/components/issues/issue-layouts/calendar/day-tile.tsx b/web/components/issues/issue-layouts/calendar/day-tile.tsx index 7d07ce7f4..2dacc03ee 100644 --- a/web/components/issues/issue-layouts/calendar/day-tile.tsx +++ b/web/components/issues/issue-layouts/calendar/day-tile.tsx @@ -4,7 +4,7 @@ import { Droppable } from "@hello-pangea/dnd"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // components -import { CalendarIssueBlocks, ICalendarDate } from "components/issues"; +import { CalendarIssueBlocks, ICalendarDate, CalendarInlineCreateIssueForm } from "components/issues"; // helpers import { renderDateFormat } from "helpers/date-time.helper"; // types @@ -17,10 +17,11 @@ type Props = { date: ICalendarDate; issues: IIssueGroupedStructure | null; quickActions: (issue: IIssue) => React.ReactNode; + enableQuickIssueCreate?: boolean; }; export const CalendarDayTile: React.FC = observer((props) => { - const { date, issues, quickActions } = props; + const { date, issues, quickActions, enableQuickIssueCreate } = props; const { issueFilter: issueFilterStore } = useMobxStore(); @@ -29,35 +30,56 @@ export const CalendarDayTile: React.FC = observer((props) => { const issuesList = issues ? (issues as IIssueGroupedStructure)[renderDateFormat(date.date)] : null; return ( - - {(provided, snapshot) => ( + <> +
+ {/* header */}
- <> -
- {date.date.getDate() === 1 && MONTHS_LIST[date.date.getMonth() + 1].shortTitle + " "} - {date.date.getDate()} -
- - {provided.placeholder} - + {date.date.getDate() === 1 && MONTHS_LIST[date.date.getMonth() + 1].shortTitle + " "} + {date.date.getDate()}
- )} - + + {/* content */} +
+ + {(provided, snapshot) => ( +
+ + {enableQuickIssueCreate && ( +
+ +
+ )} + {provided.placeholder} +
+ )} +
+
+
+ ); }); diff --git a/web/components/issues/issue-layouts/calendar/index.ts b/web/components/issues/issue-layouts/calendar/index.ts index be4383954..a33cf557b 100644 --- a/web/components/issues/issue-layouts/calendar/index.ts +++ b/web/components/issues/issue-layouts/calendar/index.ts @@ -7,3 +7,4 @@ export * from "./header"; export * from "./issue-blocks"; export * from "./week-days"; export * from "./week-header"; +export * from "./inline-create-issue-form"; diff --git a/web/components/issues/issue-layouts/calendar/inline-create-issue-form.tsx b/web/components/issues/issue-layouts/calendar/inline-create-issue-form.tsx new file mode 100644 index 000000000..3ab74c368 --- /dev/null +++ b/web/components/issues/issue-layouts/calendar/inline-create-issue-form.tsx @@ -0,0 +1,234 @@ +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/router"; +import { Transition } from "@headlessui/react"; +import { useForm } from "react-hook-form"; + +// store +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; + +// hooks +import useToast from "hooks/use-toast"; +import useKeypress from "hooks/use-keypress"; +import useProjectDetails from "hooks/use-project-details"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; + +// constants +import { createIssuePayload } from "constants/issue"; + +// icons +import { PlusIcon } from "lucide-react"; + +// types +import { IIssue } from "types"; + +type Props = { + groupId?: string; + dependencies?: any[]; + prePopulatedData?: Partial; + onSuccess?: (data: IIssue) => Promise | void; +}; + +const useCheckIfThereIsSpaceOnRight = (ref: React.RefObject, deps: any[]) => { + const [isThereSpaceOnRight, setIsThereSpaceOnRight] = useState(true); + + const router = useRouter(); + const { moduleId, cycleId, viewId } = router.query; + + const container = document.getElementById(`calendar-view-${cycleId ?? moduleId ?? viewId}`); + + useEffect(() => { + if (!ref.current) return; + + const { right } = ref.current.getBoundingClientRect(); + + const width = right; + + const innerWidth = container?.getBoundingClientRect().width ?? window.innerWidth; + + if (width > innerWidth) setIsThereSpaceOnRight(false); + else setIsThereSpaceOnRight(true); + }, [ref, deps, container]); + + return isThereSpaceOnRight; +}; + +const defaultValues: Partial = { + name: "", +}; + +const Inputs = (props: any) => { + const { register, setFocus, projectDetails } = props; + + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + return ( + <> +

{projectDetails?.identifier ?? "..."}

+ + + ); +}; + +export const CalendarInlineCreateIssueForm: React.FC = observer((props) => { + const { prePopulatedData, dependencies = [], groupId } = props; + + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + // store + const { workspace: workspaceStore, quickAddIssue: quickAddStore } = useMobxStore(); + + // ref + const ref = useRef(null); + + // states + const [isOpen, setIsOpen] = useState(false); + + const { setToastAlert } = useToast(); + + const { projectDetails } = useProjectDetails(); + + const { + reset, + handleSubmit, + register, + setFocus, + formState: { errors, isSubmitting }, + } = useForm({ defaultValues }); + + const handleClose = () => { + setIsOpen(false); + }; + + useKeypress("Escape", handleClose); + useOutsideClickDetector(ref, handleClose); + + // derived values + const workspaceDetail = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString()!); + + useEffect(() => { + if (!isOpen) reset({ ...defaultValues }); + }, [isOpen, reset]); + + useEffect(() => { + if (!errors) return; + + Object.keys(errors).forEach((key) => { + const error = errors[key as keyof IIssue]; + + setToastAlert({ + type: "error", + title: "Error!", + message: error?.message?.toString() || "Some error occurred. Please try again.", + }); + }); + }, [errors, setToastAlert]); + + const isSpaceOnRight = useCheckIfThereIsSpaceOnRight(ref, dependencies); + + const onSubmitHandler = async (formData: IIssue) => { + if (isSubmitting || !workspaceSlug || !projectId) return; + + // resetting the form so that user can add another issue quickly + reset({ ...defaultValues, ...(prePopulatedData ?? {}) }); + + const payload = createIssuePayload(workspaceDetail!, projectDetails!, { + ...(prePopulatedData ?? {}), + ...formData, + labels_list: + formData.labels_list?.length !== 0 + ? formData.labels_list + : prePopulatedData?.labels && prePopulatedData?.labels.toString() !== "none" + ? [prePopulatedData.labels as any] + : [], + assignees_list: + formData.assignees_list?.length !== 0 + ? formData.assignees_list + : prePopulatedData?.assignees && prePopulatedData?.assignees.toString() !== "none" + ? [prePopulatedData.assignees as any] + : [], + }); + + try { + quickAddStore.createIssue( + workspaceSlug.toString(), + projectId.toString(), + { + group_id: groupId ?? null, + sub_group_id: null, + }, + payload + ); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + } catch (err: any) { + Object.keys(err || {}).forEach((key) => { + const error = err?.[key]; + const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null; + + setToastAlert({ + type: "error", + title: "Error!", + message: errorTitle || "Some error occurred. Please try again.", + }); + }); + } + }; + + return ( + <> + +
+
+ + +
+
+ + {!isOpen && ( +
+ +
+ )} + + ); +}); diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index de58b8622..cddc30c68 100644 --- a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -17,42 +17,44 @@ export const CalendarIssueBlocks: React.FC = observer((props) => { const { workspaceSlug } = router.query; return ( -
+ <> {issues?.map((issue, index) => ( {(provided, snapshot) => ( - - - -
- {issue.project_detail.identifier}-{issue.sequence_id} -
-
{issue.name}
-
{quickActions(issue)}
- {/* handleIssues(issue.target_date ?? "", issue, "delete")} - handleUpdate={async (data) => handleIssues(issue.target_date ?? "", data, "update")} - /> */} -
- + + ); }); diff --git a/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx b/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx index 0f0c19f06..ec9e6b94f 100644 --- a/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx @@ -11,12 +11,16 @@ import { IIssueGroupedStructure } from "store/issue"; import { IIssue } from "types"; export const CycleCalendarLayout: React.FC = observer(() => { - const { cycleIssue: cycleIssueStore, issueFilter: issueFilterStore, issueDetail: issueDetailStore } = useMobxStore(); + const { + cycleIssue: cycleIssueStore, + issueFilter: issueFilterStore, + issueDetail: issueDetailStore, + cycleIssueCalendarView: cycleIssueCalendarViewStore, + } = useMobxStore(); const router = useRouter(); const { workspaceSlug, cycleId } = router.query; - // TODO: add drag and drop functionality const onDragEnd = (result: DropResult) => { if (!result) return; @@ -26,7 +30,7 @@ export const CycleCalendarLayout: React.FC = observer(() => { // return if dropped on the same date if (result.destination.droppableId === result.source.droppableId) return; - // issueKanBanViewStore?.handleDragDrop(result.source, result.destination); + cycleIssueCalendarViewStore?.handleDragDrop(result.source, result.destination); }; const issues = cycleIssueStore.getIssues; diff --git a/web/components/issues/issue-layouts/calendar/roots/module-root.tsx b/web/components/issues/issue-layouts/calendar/roots/module-root.tsx index c0afd5a0a..1f0219d55 100644 --- a/web/components/issues/issue-layouts/calendar/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/module-root.tsx @@ -15,12 +15,12 @@ export const ModuleCalendarLayout: React.FC = observer(() => { moduleIssue: moduleIssueStore, issueFilter: issueFilterStore, issueDetail: issueDetailStore, + moduleIssueCalendarView: moduleIssueCalendarViewStore, } = useMobxStore(); const router = useRouter(); const { workspaceSlug, moduleId } = router.query; - // TODO: add drag and drop functionality const onDragEnd = (result: DropResult) => { if (!result) return; @@ -30,7 +30,7 @@ export const ModuleCalendarLayout: React.FC = observer(() => { // return if dropped on the same date if (result.destination.droppableId === result.source.droppableId) return; - // issueKanBanViewStore?.handleDragDrop(result.source, result.destination); + moduleIssueCalendarViewStore?.handleDragDrop(result.source, result.destination); }; const issues = moduleIssueStore.getIssues; diff --git a/web/components/issues/issue-layouts/calendar/roots/project-root.tsx b/web/components/issues/issue-layouts/calendar/roots/project-root.tsx index 1d7c1cea3..96459a350 100644 --- a/web/components/issues/issue-layouts/calendar/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/project-root.tsx @@ -11,12 +11,16 @@ import { IIssueGroupedStructure } from "store/issue"; import { IIssue } from "types"; export const CalendarLayout: React.FC = observer(() => { - const { issue: issueStore, issueFilter: issueFilterStore, issueDetail: issueDetailStore } = useMobxStore(); + const { + issue: issueStore, + issueFilter: issueFilterStore, + issueDetail: issueDetailStore, + issueCalendarView: issueCalendarViewStore, + } = useMobxStore(); const router = useRouter(); const { workspaceSlug } = router.query; - // TODO: add drag and drop functionality const onDragEnd = (result: DropResult) => { if (!result) return; @@ -26,7 +30,7 @@ export const CalendarLayout: React.FC = observer(() => { // return if dropped on the same date if (result.destination.droppableId === result.source.droppableId) return; - // issueKanBanViewStore?.handleDragDrop(result.source, result.destination); + issueCalendarViewStore?.handleDragDrop(result.source, result.destination); }; const issues = issueStore.getIssues; diff --git a/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx b/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx index 5aa9e1545..6ea847609 100644 --- a/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx @@ -15,12 +15,12 @@ export const ProjectViewCalendarLayout: React.FC = observer(() => { projectViewIssues: projectViewIssuesStore, issueFilter: issueFilterStore, issueDetail: issueDetailStore, + projectViewIssueCalendarView: projectViewIssueCalendarViewStore, } = useMobxStore(); const router = useRouter(); const { workspaceSlug } = router.query; - // TODO: add drag and drop functionality const onDragEnd = (result: DropResult) => { if (!result) return; @@ -30,7 +30,7 @@ export const ProjectViewCalendarLayout: React.FC = observer(() => { // return if dropped on the same date if (result.destination.droppableId === result.source.droppableId) return; - // issueKanBanViewStore?.handleDragDrop(result.source, result.destination); + projectViewIssueCalendarViewStore?.handleDragDrop(result.source, result.destination); }; const issues = projectViewIssuesStore.getIssues; diff --git a/web/components/issues/issue-layouts/calendar/week-days.tsx b/web/components/issues/issue-layouts/calendar/week-days.tsx index 2d6d5d09a..3923acb62 100644 --- a/web/components/issues/issue-layouts/calendar/week-days.tsx +++ b/web/components/issues/issue-layouts/calendar/week-days.tsx @@ -15,10 +15,11 @@ type Props = { issues: IIssueGroupedStructure | null; week: ICalendarWeek | undefined; quickActions: (issue: IIssue) => React.ReactNode; + enableQuickIssueCreate?: boolean; }; export const CalendarWeekDays: React.FC = observer((props) => { - const { issues, week, quickActions } = props; + const { issues, week, quickActions, enableQuickIssueCreate } = props; const { issueFilter: issueFilterStore } = useMobxStore(); @@ -37,7 +38,13 @@ export const CalendarWeekDays: React.FC = observer((props) => { if (!showWeekends && (date.date.getDay() === 0 || date.date.getDay() === 6)) return null; return ( - + ); })}
diff --git a/web/components/issues/issue-layouts/gantt/index.ts b/web/components/issues/issue-layouts/gantt/index.ts index d8cfadd48..87899ae83 100644 --- a/web/components/issues/issue-layouts/gantt/index.ts +++ b/web/components/issues/issue-layouts/gantt/index.ts @@ -3,3 +3,4 @@ export * from "./cycle-root"; export * from "./module-root"; export * from "./project-view-root"; export * from "./root"; +export * from "./inline-create-issue-form"; diff --git a/web/components/issues/issue-layouts/gantt/inline-create-issue-form.tsx b/web/components/issues/issue-layouts/gantt/inline-create-issue-form.tsx new file mode 100644 index 000000000..b4edddbd1 --- /dev/null +++ b/web/components/issues/issue-layouts/gantt/inline-create-issue-form.tsx @@ -0,0 +1,196 @@ +import { useEffect, useState, useRef } from "react"; +import { useRouter } from "next/router"; +import { useForm } from "react-hook-form"; +import { Transition } from "@headlessui/react"; +import { PlusIcon } from "lucide-react"; + +// store +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; + +// constants +import { createIssuePayload } from "constants/issue"; + +// hooks +import useToast from "hooks/use-toast"; +import useKeypress from "hooks/use-keypress"; +import useProjectDetails from "hooks/use-project-details"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; + +// types +import { IIssue } from "types"; +import { renderDateFormat } from "helpers/date-time.helper"; + +type Props = { + prePopulatedData?: Partial; + onSuccess?: (data: IIssue) => Promise | void; +}; + +const defaultValues: Partial = { + name: "", +}; + +const Inputs = (props: any) => { + const { register, setFocus } = props; + + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + return ( + + ); +}; + +export const GanttInlineCreateIssueForm: React.FC = observer((props) => { + const { prePopulatedData } = props; + + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + // store + const { workspace: workspaceStore, quickAddIssue: quickAddStore } = useMobxStore(); + + const { projectDetails } = useProjectDetails(); + + const { + reset, + handleSubmit, + setFocus, + register, + formState: { errors, isSubmitting }, + } = useForm({ defaultValues }); + + // ref + const ref = useRef(null); + + // states + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => setIsOpen(false); + + // hooks + useKeypress("Escape", handleClose); + useOutsideClickDetector(ref, handleClose); + const { setToastAlert } = useToast(); + + // derived values + const workspaceDetail = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString()!); + + useEffect(() => { + if (!isOpen) reset({ ...defaultValues }); + }, [isOpen, reset]); + + useEffect(() => { + if (!errors) return; + + Object.keys(errors).forEach((key) => { + const error = errors[key as keyof IIssue]; + + setToastAlert({ + type: "error", + title: "Error!", + message: error?.message?.toString() || "Some error occurred. Please try again.", + }); + }); + }, [errors, setToastAlert]); + + const onSubmitHandler = async (formData: IIssue) => { + if (isSubmitting || !workspaceSlug || !projectId) return; + + // resetting the form so that user can add another issue quickly + reset({ ...defaultValues, ...(prePopulatedData ?? {}) }); + + const payload = createIssuePayload(workspaceDetail!, projectDetails!, { + ...(prePopulatedData ?? {}), + ...formData, + labels_list: + formData.labels_list?.length !== 0 + ? formData.labels_list + : prePopulatedData?.labels && prePopulatedData?.labels.toString() !== "none" + ? [prePopulatedData.labels as any] + : [], + start_date: renderDateFormat(new Date()), + target_date: renderDateFormat(new Date(new Date().getTime() + 24 * 60 * 60 * 1000)), + }); + + try { + quickAddStore.createIssue( + workspaceSlug.toString(), + projectId.toString(), + { + group_id: null, + sub_group_id: null, + }, + payload + ); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + } catch (err: any) { + Object.keys(err || {}).forEach((key) => { + const error = err?.[key]; + const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null; + + setToastAlert({ + type: "error", + title: "Error!", + message: errorTitle || "Some error occurred. Please try again.", + }); + }); + } + }; + + return ( + <> + +
+
+

{projectDetails?.identifier ?? "..."}

+ + + + + {isOpen && ( +

+ Press {"'"}Enter{"'"} to add another issue +

+ )} + + {!isOpen && ( + + )} + + ); +}); diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 34f0a550e..3cbb4e7d5 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -2,7 +2,7 @@ import { Draggable } from "@hello-pangea/dnd"; // components import { KanBanProperties } from "./properties"; // types -import { IIssue } from "types"; +import { IEstimatePoint, IIssue, IIssueLabels, IState, IUserLite } from "types"; interface IssueBlockProps { sub_group_id: string; @@ -18,10 +18,27 @@ interface IssueBlockProps { ) => void; quickActions: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => React.ReactNode; displayProperties: any; + states: IState[] | null; + labels: IIssueLabels[] | null; + members: IUserLite[] | null; + estimates: IEstimatePoint[] | null; } export const KanbanIssueBlock: React.FC = (props) => { - const { sub_group_id, columnId, index, issue, isDragDisabled, handleIssues, quickActions, displayProperties } = props; + const { + sub_group_id, + columnId, + index, + issue, + isDragDisabled, + handleIssues, + quickActions, + displayProperties, + states, + labels, + members, + estimates, + } = props; const updateIssue = (sub_group_by: string | null, group_by: string | null, issueToUpdate: IIssue) => { if (issueToUpdate) handleIssues(sub_group_by, group_by, issueToUpdate, "update"); @@ -37,6 +54,9 @@ export const KanbanIssueBlock: React.FC = (props) => { {...provided.dragHandleProps} ref={provided.innerRef} > + {issue.tempId !== undefined && ( +
+ )}
{quickActions( !sub_group_id && sub_group_id === "null" ? null : sub_group_id, @@ -54,7 +74,7 @@ export const KanbanIssueBlock: React.FC = (props) => { {issue.project_detail.identifier}-{issue.sequence_id}
)} -
{issue.name}
+
{issue.name}
= (props) => { issue={issue} handleIssues={updateIssue} display_properties={displayProperties} + states={states} + labels={labels} + members={members} + estimates={estimates} />
diff --git a/web/components/issues/issue-layouts/kanban/blocks-list.tsx b/web/components/issues/issue-layouts/kanban/blocks-list.tsx index aeee5c2fc..0e921638a 100644 --- a/web/components/issues/issue-layouts/kanban/blocks-list.tsx +++ b/web/components/issues/issue-layouts/kanban/blocks-list.tsx @@ -1,6 +1,6 @@ // components import { KanbanIssueBlock } from "components/issues"; -import { IIssue } from "types"; +import { IEstimatePoint, IIssue, IIssueLabels, IState, IUserLite } from "types"; interface IssueBlocksListProps { sub_group_id: string; @@ -15,10 +15,26 @@ interface IssueBlocksListProps { ) => void; quickActions: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => React.ReactNode; display_properties: any; + states: IState[] | null; + labels: IIssueLabels[] | null; + members: IUserLite[] | null; + estimates: IEstimatePoint[] | null; } export const KanbanIssueBlocksList: React.FC = (props) => { - const { sub_group_id, columnId, issues, isDragDisabled, handleIssues, quickActions, display_properties } = props; + const { + sub_group_id, + columnId, + issues, + isDragDisabled, + handleIssues, + quickActions, + display_properties, + states, + labels, + members, + estimates, + } = props; return ( <> @@ -35,6 +51,10 @@ export const KanbanIssueBlocksList: React.FC = (props) => columnId={columnId} sub_group_id={sub_group_id} isDragDisabled={isDragDisabled} + states={states} + labels={labels} + members={members} + estimates={estimates} /> ))} diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index 691e50fb9..354cf011f 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -5,9 +5,9 @@ import { Droppable } from "@hello-pangea/dnd"; import { useMobxStore } from "lib/mobx/store-provider"; // components import { KanBanGroupByHeaderRoot } from "./headers/group-by-root"; -import { KanbanIssueBlocksList } from "components/issues"; +import { KanbanIssueBlocksList, BoardInlineCreateIssueForm } from "components/issues"; // types -import { IIssue } from "types"; +import { IEstimatePoint, IIssue, IIssueLabels, IProject, IState, IUserLite } from "types"; // constants import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES, getValueFromObject } from "constants/issue"; @@ -29,6 +29,12 @@ export interface IGroupByKanBan { display_properties: any; kanBanToggle: any; handleKanBanToggle: any; + enableQuickIssueCreate?: boolean; + states: IState[] | null; + labels: IIssueLabels[] | null; + members: IUserLite[] | null; + priorities: any; + estimates: IEstimatePoint[] | null; } const GroupByKanBan: React.FC = observer((props) => { @@ -45,6 +51,12 @@ const GroupByKanBan: React.FC = observer((props) => { display_properties, kanBanToggle, handleKanBanToggle, + states, + labels, + members, + priorities, + estimates, + enableQuickIssueCreate, } = props; const verticalAlignPosition = (_list: any) => @@ -93,6 +105,10 @@ const GroupByKanBan: React.FC = observer((props) => { handleIssues={handleIssues} quickActions={quickActions} display_properties={display_properties} + states={states} + labels={labels} + members={members} + estimates={estimates} /> ) : ( isDragDisabled && ( @@ -106,6 +122,16 @@ const GroupByKanBan: React.FC = observer((props) => { )}
+ {enableQuickIssueCreate && ( + + )} ))} @@ -128,14 +154,14 @@ export interface IKanBan { display_properties: any; kanBanToggle: any; handleKanBanToggle: any; - - states: any; + states: IState[] | null; stateGroups: any; priorities: any; - labels: any; - members: any; - projects: any; - estimates: any; + labels: IIssueLabels[] | null; + members: IUserLite[] | null; + projects: IProject[] | null; + estimates: IEstimatePoint[] | null; + enableQuickIssueCreate?: boolean; } export const KanBan: React.FC = observer((props) => { @@ -156,6 +182,7 @@ export const KanBan: React.FC = observer((props) => { members, projects, estimates, + enableQuickIssueCreate, } = props; const { project: projectStore, issueKanBanView: issueKanBanViewStore } = useMobxStore(); @@ -176,6 +203,12 @@ export const KanBan: React.FC = observer((props) => { display_properties={display_properties} kanBanToggle={kanBanToggle} handleKanBanToggle={handleKanBanToggle} + enableQuickIssueCreate={enableQuickIssueCreate} + states={states} + labels={labels} + members={members} + priorities={priorities} + estimates={estimates} /> )} @@ -193,6 +226,12 @@ export const KanBan: React.FC = observer((props) => { display_properties={display_properties} kanBanToggle={kanBanToggle} handleKanBanToggle={handleKanBanToggle} + enableQuickIssueCreate={enableQuickIssueCreate} + states={states} + labels={labels} + members={members} + priorities={priorities} + estimates={estimates} /> )} @@ -210,6 +249,12 @@ export const KanBan: React.FC = observer((props) => { display_properties={display_properties} kanBanToggle={kanBanToggle} handleKanBanToggle={handleKanBanToggle} + enableQuickIssueCreate={enableQuickIssueCreate} + states={states} + labels={labels} + members={members} + priorities={priorities} + estimates={estimates} /> )} @@ -227,6 +272,12 @@ export const KanBan: React.FC = observer((props) => { display_properties={display_properties} kanBanToggle={kanBanToggle} handleKanBanToggle={handleKanBanToggle} + enableQuickIssueCreate={enableQuickIssueCreate} + states={states} + labels={labels} + members={members} + priorities={priorities} + estimates={estimates} /> )} @@ -244,6 +295,12 @@ export const KanBan: React.FC = observer((props) => { display_properties={display_properties} kanBanToggle={kanBanToggle} handleKanBanToggle={handleKanBanToggle} + enableQuickIssueCreate={enableQuickIssueCreate} + states={states} + labels={labels} + members={members} + priorities={priorities} + estimates={estimates} /> )} @@ -261,6 +318,12 @@ export const KanBan: React.FC = observer((props) => { display_properties={display_properties} kanBanToggle={kanBanToggle} handleKanBanToggle={handleKanBanToggle} + enableQuickIssueCreate={enableQuickIssueCreate} + states={states} + labels={labels} + members={members} + priorities={priorities} + estimates={estimates} /> )} diff --git a/web/components/issues/issue-layouts/kanban/index.ts b/web/components/issues/issue-layouts/kanban/index.ts index 3adfe5c26..761f32a77 100644 --- a/web/components/issues/issue-layouts/kanban/index.ts +++ b/web/components/issues/issue-layouts/kanban/index.ts @@ -1,5 +1,4 @@ export * from "./block"; +export * from "./roots"; export * from "./blocks-list"; -export * from "./cycle-root"; -export * from "./module-root"; -export * from "./root"; +export * from "./inline-create-issue-form"; diff --git a/web/components/issues/issue-layouts/kanban/inline-create-issue-form.tsx b/web/components/issues/issue-layouts/kanban/inline-create-issue-form.tsx new file mode 100644 index 000000000..cad0814b8 --- /dev/null +++ b/web/components/issues/issue-layouts/kanban/inline-create-issue-form.tsx @@ -0,0 +1,202 @@ +import { useEffect, useState, useRef } from "react"; +import { useRouter } from "next/router"; +import { useForm } from "react-hook-form"; +import { Transition } from "@headlessui/react"; +import { PlusIcon } from "lucide-react"; +// store +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; + +// hooks +import useToast from "hooks/use-toast"; +import useKeypress from "hooks/use-keypress"; +import useProjectDetails from "hooks/use-project-details"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; + +// constants +import { createIssuePayload } from "constants/issue"; + +// types +import { IIssue } from "types"; + +type Props = { + groupId?: string; + subGroupId?: string; + prePopulatedData?: Partial; + onSuccess?: (data: IIssue) => Promise | void; +}; + +const defaultValues: Partial = { + name: "", +}; + +const Inputs = (props: any) => { + const { register, setFocus, projectDetails } = props; + + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + return ( +
+

{projectDetails?.identifier ?? "..."}

+ +
+ ); +}; + +export const BoardInlineCreateIssueForm: React.FC = observer((props) => { + const { prePopulatedData, groupId, subGroupId } = props; + + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + // store + const { workspace: workspaceStore, quickAddIssue: quickAddStore } = useMobxStore(); + + // ref + const ref = useRef(null); + + // states + const [isOpen, setIsOpen] = useState(false); + + const { setToastAlert } = useToast(); + + const { projectDetails } = useProjectDetails(); + + const { + reset, + handleSubmit, + register, + setFocus, + formState: { errors, isSubmitting }, + } = useForm({ defaultValues }); + + const handleClose = () => { + setIsOpen(false); + }; + + useKeypress("Escape", handleClose); + useOutsideClickDetector(ref, handleClose); + + // derived values + const workspaceDetail = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString()!); + + useEffect(() => { + if (!isOpen) reset({ ...defaultValues }); + }, [isOpen, reset]); + + useEffect(() => { + if (!errors) return; + + Object.keys(errors).forEach((key) => { + const error = errors[key as keyof IIssue]; + + setToastAlert({ + type: "error", + title: "Error!", + message: error?.message?.toString() || "Some error occurred. Please try again.", + }); + }); + }, [errors, setToastAlert]); + + const onSubmitHandler = async (formData: IIssue) => { + if (isSubmitting || !workspaceSlug || !projectId) return; + + // resetting the form so that user can add another issue quickly + reset({ ...defaultValues, ...(prePopulatedData ?? {}) }); + + const payload = createIssuePayload(workspaceDetail!, projectDetails!, { + ...(prePopulatedData ?? {}), + ...formData, + labels_list: + formData.labels_list && formData.labels_list.length !== 0 + ? formData.labels_list + : prePopulatedData?.labels && prePopulatedData?.labels.toString() !== "none" + ? [prePopulatedData.labels as any] + : [], + assignees_list: + formData.assignees_list && formData.assignees_list.length !== 0 + ? formData.assignees_list + : prePopulatedData?.assignees && prePopulatedData?.assignees.toString() !== "none" + ? [prePopulatedData.assignees as any] + : [], + }); + + try { + quickAddStore.createIssue( + workspaceSlug.toString(), + projectId.toString(), + { + group_id: groupId ?? null, + sub_group_id: subGroupId ?? null, + }, + payload + ); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + } catch (err: any) { + Object.keys(err || {}).forEach((key) => { + const error = err?.[key]; + const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null; + + setToastAlert({ + type: "error", + title: "Error!", + message: errorTitle || "Some error occurred. Please try again.", + }); + }); + } + }; + + return ( +
+ +
+ + +
+ + {isOpen && ( +

+ Press {"'"}Enter{"'"} to add another issue +

+ )} + + {!isOpen && ( + + )} +
+ ); +}); diff --git a/web/components/issues/issue-layouts/kanban/properties.tsx b/web/components/issues/issue-layouts/kanban/properties.tsx index 979ead23b..e321094d4 100644 --- a/web/components/issues/issue-layouts/kanban/properties.tsx +++ b/web/components/issues/issue-layouts/kanban/properties.tsx @@ -10,190 +10,196 @@ import { IssuePropertyAssignee } from "../properties/assignee"; import { IssuePropertyEstimates } from "../properties/estimates"; import { IssuePropertyDate } from "../properties/date"; import { Tooltip } from "@plane/ui"; +import { IEstimatePoint, IIssue, IIssueLabels, IState, IUserLite, TIssuePriorities } from "types"; export interface IKanBanProperties { sub_group_id: string; columnId: string; - issue: any; - handleIssues?: (sub_group_by: string | null, group_by: string | null, issue: any) => void; + issue: IIssue; + handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => void; display_properties: any; + states: IState[] | null; + labels: IIssueLabels[] | null; + members: IUserLite[] | null; + estimates: IEstimatePoint[] | null; } -export const KanBanProperties: React.FC = observer( - ({ sub_group_id, columnId: group_id, issue, handleIssues, display_properties }) => { - const handleState = (id: string) => { - if (handleIssues) - handleIssues( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !group_id && group_id === "null" ? null : group_id, - { ...issue, state: id } - ); - }; +export const KanBanProperties: React.FC = observer((props) => { + const { + sub_group_id, + columnId: group_id, + issue, + handleIssues, + display_properties, + states, + labels, + members, + estimates, + } = props; - const handlePriority = (id: string) => { - if (handleIssues) - handleIssues( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !group_id && group_id === "null" ? null : group_id, - { ...issue, priority: id } - ); - }; - - const handleLabel = (ids: string[]) => { - if (handleIssues) - handleIssues( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !group_id && group_id === "null" ? null : group_id, - { ...issue, labels: ids } - ); - }; - - const handleAssignee = (ids: string[]) => { - if (handleIssues) - handleIssues( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !group_id && group_id === "null" ? null : group_id, - { ...issue, assignees: ids } - ); - }; - - const handleStartDate = (date: string) => { - if (handleIssues) - handleIssues( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !group_id && group_id === "null" ? null : group_id, - { ...issue, start_date: date } - ); - }; - - const handleTargetDate = (date: string) => { - if (handleIssues) - handleIssues( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !group_id && group_id === "null" ? null : group_id, - { ...issue, target_date: date } - ); - }; - - const handleEstimate = (id: string) => { - if (handleIssues) - handleIssues( - !sub_group_id && sub_group_id === "null" ? null : sub_group_id, - !group_id && group_id === "null" ? null : group_id, - { ...issue, estimate_point: id } - ); - }; - - return ( -
- {/* basic properties */} - {/* state */} - {display_properties && display_properties?.state && ( - handleState(id)} - disabled={false} - /> - )} - - {/* priority */} - {display_properties && display_properties?.priority && ( - handlePriority(id)} - disabled={false} - /> - )} - - {/* label */} - {display_properties && display_properties?.labels && ( - handleLabel(ids)} - disabled={false} - /> - )} - - {/* assignee */} - {display_properties && display_properties?.assignee && ( - handleAssignee(ids)} - disabled={false} - /> - )} - - {/* start date */} - {display_properties && display_properties?.start_date && ( - handleStartDate(date)} - disabled={false} - /> - )} - - {/* target/due date */} - {display_properties && display_properties?.due_date && ( - handleTargetDate(date)} - disabled={false} - /> - )} - - {/* estimates */} - {display_properties && display_properties?.estimate && ( - handleEstimate(id)} - disabled={false} - workspaceSlug={issue?.workspace_detail?.slug || null} - projectId={issue?.project_detail?.id || null} - /> - )} - - {/* extra render properties */} - {/* sub-issues */} - {display_properties && display_properties?.sub_issue_count && ( - -
-
- -
-
{issue.sub_issues_count}
-
-
- )} - - {/* attachments */} - {display_properties && display_properties?.attachment_count && ( - -
-
- -
-
{issue.attachment_count}
-
-
- )} - - {/* link */} - {display_properties && display_properties?.link && ( - -
-
- -
-
{issue.link_count}
-
-
- )} -
+ const handleState = (state: IState) => { + handleIssues( + !sub_group_id && sub_group_id === "null" ? null : sub_group_id, + !group_id && group_id === "null" ? null : group_id, + { ...issue, state: state.id } ); - } -); + }; + + const handlePriority = (value: TIssuePriorities) => { + handleIssues( + !sub_group_id && sub_group_id === "null" ? null : sub_group_id, + !group_id && group_id === "null" ? null : group_id, + { ...issue, priority: value } + ); + }; + + const handleLabel = (ids: string[]) => { + handleIssues( + !sub_group_id && sub_group_id === "null" ? null : sub_group_id, + !group_id && group_id === "null" ? null : group_id, + { ...issue, labels_list: ids } + ); + }; + + const handleAssignee = (ids: string[]) => { + handleIssues( + !sub_group_id && sub_group_id === "null" ? null : sub_group_id, + !group_id && group_id === "null" ? null : group_id, + { ...issue, assignees_list: ids } + ); + }; + + const handleStartDate = (date: string) => { + handleIssues( + !sub_group_id && sub_group_id === "null" ? null : sub_group_id, + !group_id && group_id === "null" ? null : group_id, + { ...issue, start_date: date } + ); + }; + + const handleTargetDate = (date: string) => { + handleIssues( + !sub_group_id && sub_group_id === "null" ? null : sub_group_id, + !group_id && group_id === "null" ? null : group_id, + { ...issue, target_date: date } + ); + }; + + const handleEstimate = (value: number | null) => { + handleIssues( + !sub_group_id && sub_group_id === "null" ? null : sub_group_id, + !group_id && group_id === "null" ? null : group_id, + { ...issue, estimate_point: value } + ); + }; + + return ( +
+ {/* basic properties */} + {/* state */} + {display_properties && display_properties?.state && ( + + )} + + {/* priority */} + {display_properties && display_properties?.priority && ( + + )} + + {/* label */} + {display_properties && display_properties?.labels && ( + + )} + + {/* assignee */} + {display_properties && display_properties?.assignee && ( + + )} + + {/* start date */} + {display_properties && display_properties?.start_date && ( + handleStartDate(date)} + disabled={false} + placeHolder="Start date" + /> + )} + + {/* target/due date */} + {display_properties && display_properties?.due_date && ( + handleTargetDate(date)} + disabled={false} + placeHolder="Target date" + /> + )} + + {/* estimates */} + {display_properties && display_properties?.estimate && ( + + )} + + {/* extra render properties */} + {/* sub-issues */} + {display_properties && display_properties?.sub_issue_count && ( + +
+ +
{issue.sub_issues_count}
+
+
+ )} + + {/* attachments */} + {display_properties && display_properties?.attachment_count && ( + +
+ +
{issue.attachment_count}
+
+
+ )} + + {/* link */} + {display_properties && display_properties?.link && ( + +
+ +
{issue.link_count}
+
+
+ )} +
+ ); +}); diff --git a/web/components/issues/issue-layouts/kanban/cycle-root.tsx b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx similarity index 83% rename from web/components/issues/issue-layouts/kanban/cycle-root.tsx rename to web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx index f4d09aada..188e27a68 100644 --- a/web/components/issues/issue-layouts/kanban/cycle-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx @@ -5,9 +5,11 @@ import { DragDropContext } from "@hello-pangea/dnd"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // components -import { KanBanSwimLanes } from "./swimlanes"; -import { KanBan } from "./default"; +import { KanBanSwimLanes } from "../swimlanes"; +import { KanBan } from "../default"; import { CycleIssueQuickActions } from "components/issues"; +// helpers +import { orderArrayBy } from "helpers/array.helper"; // types import { IIssue } from "types"; // constants @@ -25,7 +27,7 @@ export const CycleKanBanLayout: React.FC = observer(() => { } = useMobxStore(); const router = useRouter(); - const { workspaceSlug, cycleId } = router.query; + const { workspaceSlug, projectId, cycleId } = router.query; const issues = cycleIssueStore?.getIssues; @@ -60,12 +62,12 @@ export const CycleKanBanLayout: React.FC = observer(() => { if (!workspaceSlug || !cycleId) return; if (action === "update") { - cycleIssueStore.updateIssueStructure(group_by, null, issue); + cycleIssueStore.updateIssueStructure(group_by, sub_group_by, issue); issueDetailStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue); } - if (action === "delete") cycleIssueStore.deleteIssue(group_by, null, issue); + if (action === "delete") cycleIssueStore.deleteIssue(group_by, sub_group_by, issue); if (action === "remove" && issue.bridge_id) { - cycleIssueStore.deleteIssue(group_by, null, issue); + cycleIssueStore.deleteIssue(group_by, sub_group_by, issue); cycleIssueStore.removeIssueFromCycle( workspaceSlug.toString(), issue.project, @@ -81,13 +83,18 @@ export const CycleKanBanLayout: React.FC = observer(() => { cycleIssueKanBanViewStore.handleKanBanToggle(toggle, value); }; + const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : null; + const states = projectStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; const labels = projectStore?.projectLabels || null; const members = projectStore?.projectMembers || null; const stateGroups = ISSUE_STATE_GROUPS || null; - const projects = projectStore?.projectStates || null; - const estimates = null; + const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null; + const estimates = + projectDetails?.estimate !== null + ? projectStore.projectEstimates?.find((e) => e.id === projectDetails?.estimate) || null + : null; return (
@@ -113,9 +120,9 @@ export const CycleKanBanLayout: React.FC = observer(() => { stateGroups={stateGroups} priorities={priorities} labels={labels} - members={members} + members={members?.map((m) => m.member) ?? null} projects={projects} - estimates={estimates} + estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null} /> ) : ( { stateGroups={stateGroups} priorities={priorities} labels={labels} - members={members} + members={members?.map((m) => m.member) ?? null} projects={projects} - estimates={estimates} + estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null} /> )} diff --git a/web/components/issues/issue-layouts/kanban/roots/index.ts b/web/components/issues/issue-layouts/kanban/roots/index.ts new file mode 100644 index 000000000..139c09a7a --- /dev/null +++ b/web/components/issues/issue-layouts/kanban/roots/index.ts @@ -0,0 +1,5 @@ +export * from "./cycle-root"; +export * from "./module-root"; +export * from "./profile-issues-root"; +export * from "./project-root"; +export * from "./project-view-root"; diff --git a/web/components/issues/issue-layouts/kanban/module-root.tsx b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx similarity index 85% rename from web/components/issues/issue-layouts/kanban/module-root.tsx rename to web/components/issues/issue-layouts/kanban/roots/module-root.tsx index 594f15757..754693d11 100644 --- a/web/components/issues/issue-layouts/kanban/module-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx @@ -5,9 +5,11 @@ import { DragDropContext } from "@hello-pangea/dnd"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // components -import { KanBanSwimLanes } from "./swimlanes"; -import { KanBan } from "./default"; +import { KanBanSwimLanes } from "../swimlanes"; +import { KanBan } from "../default"; import { ModuleIssueQuickActions } from "components/issues"; +// helpers +import { orderArrayBy } from "helpers/array.helper"; // types import { IIssue } from "types"; // constants @@ -25,7 +27,7 @@ export const ModuleKanBanLayout: React.FC = observer(() => { } = useMobxStore(); const router = useRouter(); - const { workspaceSlug, moduleId } = router.query; + const { workspaceSlug, projectId, moduleId } = router.query; const issues = moduleIssueStore?.getIssues; @@ -81,13 +83,18 @@ export const ModuleKanBanLayout: React.FC = observer(() => { moduleIssueKanBanViewStore.handleKanBanToggle(toggle, value); }; + const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : null; + const states = projectStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; const labels = projectStore?.projectLabels || null; const members = projectStore?.projectMembers || null; const stateGroups = ISSUE_STATE_GROUPS || null; - const projects = projectStore?.projectStates || null; - const estimates = null; + const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null; + const estimates = + projectDetails?.estimate !== null + ? projectStore.projectEstimates?.find((e) => e.id === projectDetails?.estimate) || null + : null; return (
@@ -113,9 +120,9 @@ export const ModuleKanBanLayout: React.FC = observer(() => { stateGroups={stateGroups} priorities={priorities} labels={labels} - members={members} + members={members?.map((m) => m.member) ?? null} projects={projects} - estimates={estimates} + estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null} /> ) : ( { stateGroups={stateGroups} priorities={priorities} labels={labels} - members={members} + members={members?.map((m) => m.member) ?? null} projects={projects} - estimates={estimates} + estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null} /> )} diff --git a/web/components/issues/issue-layouts/kanban/profile-issues-root.tsx b/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx similarity index 94% rename from web/components/issues/issue-layouts/kanban/profile-issues-root.tsx rename to web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx index badcd04aa..d2346120a 100644 --- a/web/components/issues/issue-layouts/kanban/profile-issues-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx @@ -5,8 +5,8 @@ import { DragDropContext } from "@hello-pangea/dnd"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // components -import { KanBanSwimLanes } from "./swimlanes"; -import { KanBan } from "./default"; +import { KanBanSwimLanes } from "../swimlanes"; +import { KanBan } from "../default"; import { ProjectIssueQuickActions } from "components/issues"; // constants import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES } from "constants/issue"; @@ -79,7 +79,6 @@ export const ProfileIssuesKanBanLayout: FC = observer(() => { const members = projectStore?.projectMembers || null; const stateGroups = ISSUE_STATE_GROUPS || null; const projects = projectStore?.workspaceProjects || null; - const estimates = null; return (
@@ -104,9 +103,9 @@ export const ProfileIssuesKanBanLayout: FC = observer(() => { stateGroups={stateGroups} priorities={priorities} labels={labels} - members={members} + members={members?.map((m) => m.member) ?? null} projects={projects} - estimates={estimates} + estimates={null} /> ) : ( { stateGroups={stateGroups} priorities={priorities} labels={labels} - members={members} + members={members?.map((m) => m.member) ?? null} projects={projects} - estimates={estimates} + estimates={null} /> )} diff --git a/web/components/issues/issue-layouts/kanban/root.tsx b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx similarity index 82% rename from web/components/issues/issue-layouts/kanban/root.tsx rename to web/components/issues/issue-layouts/kanban/roots/project-root.tsx index 741e878a0..ab087e5c8 100644 --- a/web/components/issues/issue-layouts/kanban/root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx @@ -1,13 +1,15 @@ -import { FC, useCallback } from "react"; +import { useCallback } from "react"; import { useRouter } from "next/router"; import { DragDropContext } from "@hello-pangea/dnd"; import { observer } from "mobx-react-lite"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // components -import { KanBanSwimLanes } from "./swimlanes"; -import { KanBan } from "./default"; +import { KanBanSwimLanes } from "../swimlanes"; +import { KanBan } from "../default"; import { ProjectIssueQuickActions } from "components/issues"; +// helpers +import { orderArrayBy } from "helpers/array.helper"; // types import { IIssue } from "types"; // constants @@ -15,9 +17,9 @@ import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES } from "constants/issue"; export interface IKanBanLayout {} -export const KanBanLayout: FC = observer(() => { +export const KanBanLayout: React.FC = observer(() => { const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; const { project: projectStore, @@ -72,13 +74,18 @@ export const KanBanLayout: FC = observer(() => { issueKanBanViewStore.handleKanBanToggle(toggle, value); }; + const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : null; + const states = projectStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; const labels = projectStore?.projectLabels || null; const members = projectStore?.projectMembers || null; const stateGroups = ISSUE_STATE_GROUPS || null; - const projects = projectStore?.projectStates || null; - const estimates = null; + const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null; + const estimates = + projectDetails?.estimate !== null + ? projectStore.projectEstimates?.find((e) => e.id === projectDetails?.estimate) || null + : null; return (
@@ -103,9 +110,10 @@ export const KanBanLayout: FC = observer(() => { stateGroups={stateGroups} priorities={priorities} labels={labels} - members={members} + members={members?.map((m) => m.member) ?? null} projects={projects} - estimates={estimates} + enableQuickIssueCreate + estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null} /> ) : ( { stateGroups={stateGroups} priorities={priorities} labels={labels} - members={members} + members={members?.map((m) => m.member) ?? null} projects={projects} - estimates={estimates} + estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null} /> )} diff --git a/web/components/issues/issue-layouts/kanban/view-root.tsx b/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx similarity index 95% rename from web/components/issues/issue-layouts/kanban/view-root.tsx rename to web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx index 78117da80..583835ba3 100644 --- a/web/components/issues/issue-layouts/kanban/view-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx @@ -4,8 +4,8 @@ import { DragDropContext } from "@hello-pangea/dnd"; // mobx import { observer } from "mobx-react-lite"; // components -import { KanBanSwimLanes } from "./swimlanes"; -import { KanBan } from "./default"; +import { KanBanSwimLanes } from "../swimlanes"; +import { KanBan } from "../default"; // store import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; @@ -14,7 +14,7 @@ import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES } from "constants/issue"; export interface IViewKanBanLayout {} -export const ViewKanBanLayout: React.FC = observer(() => { +export const ProjectViewKanBanLayout: React.FC = observer(() => { const { project: projectStore, issue: issueStore, diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index 9090162c0..7a3126cb2 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -7,7 +7,7 @@ import { KanBanGroupByHeaderRoot } from "./headers/group-by-root"; import { KanBanSubGroupByHeaderRoot } from "./headers/sub-group-by-root"; import { KanBan } from "./default"; // types -import { IIssue } from "types"; +import { IEstimatePoint, IIssue, IIssueLabels, IProject, IState, IUserLite } from "types"; // constants import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES, getValueFromObject } from "constants/issue"; @@ -19,6 +19,11 @@ interface ISubGroupSwimlaneHeader { listKey: string; kanBanToggle: any; handleKanBanToggle: any; + states: IState[] | null; + labels: IIssueLabels[] | null; + members: IUserLite[] | null; + projects: IProject[] | null; + estimates: IEstimatePoint[] | null; } const SubGroupSwimlaneHeader: React.FC = ({ issues, @@ -71,13 +76,13 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { display_properties: any; kanBanToggle: any; handleKanBanToggle: any; - states: any; + states: IState[] | null; stateGroups: any; priorities: any; - labels: any; - members: any; - projects: any; - estimates: any; + labels: IIssueLabels[] | null; + members: IUserLite[] | null; + projects: IProject[] | null; + estimates: IEstimatePoint[] | null; } const SubGroupSwimlane: React.FC = observer((props) => { const { @@ -148,6 +153,7 @@ const SubGroupSwimlane: React.FC = observer((props) => { members={members} projects={projects} estimates={estimates} + enableQuickIssueCreate />
)} @@ -171,13 +177,13 @@ export interface IKanBanSwimLanes { display_properties: any; kanBanToggle: any; handleKanBanToggle: any; - states: any; + states: IState[] | null; stateGroups: any; priorities: any; - labels: any; - members: any; - projects: any; - estimates: any; + labels: IIssueLabels[] | null; + members: IUserLite[] | null; + projects: IProject[] | null; + estimates: IEstimatePoint[] | null; } export const KanBanSwimLanes: React.FC = observer((props) => { @@ -213,6 +219,11 @@ export const KanBanSwimLanes: React.FC = observer((props) => { listKey={`id`} kanBanToggle={kanBanToggle} handleKanBanToggle={handleKanBanToggle} + states={states} + labels={labels} + members={members} + projects={projects} + estimates={estimates} /> )} @@ -225,6 +236,11 @@ export const KanBanSwimLanes: React.FC = observer((props) => { listKey={`key`} kanBanToggle={kanBanToggle} handleKanBanToggle={handleKanBanToggle} + states={states} + labels={labels} + members={members} + projects={projects} + estimates={estimates} /> )} @@ -237,6 +253,11 @@ export const KanBanSwimLanes: React.FC = observer((props) => { listKey={`key`} kanBanToggle={kanBanToggle} handleKanBanToggle={handleKanBanToggle} + states={states} + labels={labels} + members={members} + projects={projects} + estimates={estimates} /> )} @@ -249,6 +270,11 @@ export const KanBanSwimLanes: React.FC = observer((props) => { listKey={`id`} kanBanToggle={kanBanToggle} handleKanBanToggle={handleKanBanToggle} + states={states} + labels={labels} + members={members} + projects={projects} + estimates={estimates} /> )} @@ -261,6 +287,11 @@ export const KanBanSwimLanes: React.FC = observer((props) => { listKey={`member.id`} kanBanToggle={kanBanToggle} handleKanBanToggle={handleKanBanToggle} + states={states} + labels={labels} + members={members} + projects={projects} + estimates={estimates} /> )} @@ -273,6 +304,11 @@ export const KanBanSwimLanes: React.FC = observer((props) => { listKey={`member.id`} kanBanToggle={kanBanToggle} handleKanBanToggle={handleKanBanToggle} + states={states} + labels={labels} + members={members} + projects={projects} + estimates={estimates} /> )}
diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 47a1a38f0..5a84c5f9e 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -4,7 +4,7 @@ import { IssuePeekOverview } from "components/issues/issue-peek-overview"; // ui import { Tooltip } from "@plane/ui"; // types -import { IIssue } from "types"; +import { IEstimatePoint, IIssue, IIssueLabels, IState, IUserLite } from "types"; interface IssueBlockProps { columnId: string; @@ -12,28 +12,30 @@ interface IssueBlockProps { handleIssues: (group_by: string | null, issue: IIssue, action: "update" | "delete") => void; quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode; display_properties: any; - states: any; - labels: any; - members: any; - priorities: any; + states: IState[] | null; + labels: IIssueLabels[] | null; + members: IUserLite[] | null; + estimates: IEstimatePoint[] | null; } export const IssueBlock: React.FC = (props) => { - const { columnId, issue, handleIssues, quickActions, display_properties, states, labels, members, priorities } = - props; + const { columnId, issue, handleIssues, quickActions, display_properties, states, labels, members, estimates } = props; const updateIssue = (group_by: string | null, issueToUpdate: IIssue) => { - if (issueToUpdate && handleIssues) handleIssues(group_by, issueToUpdate, "update"); + handleIssues(group_by, issueToUpdate, "update"); }; return ( <> -
+
{display_properties && display_properties?.key && (
{issue?.project_detail?.identifier}-{issue.sequence_id}
)} + {issue?.tempId !== undefined && ( +
+ )} = (props) => { states={states} labels={labels} members={members} - priorities={priorities} + estimates={estimates} /> {quickActions(!columnId && columnId === "null" ? null : columnId, issue)}
diff --git a/web/components/issues/issue-layouts/list/blocks-list.tsx b/web/components/issues/issue-layouts/list/blocks-list.tsx index 33618505f..3267e221c 100644 --- a/web/components/issues/issue-layouts/list/blocks-list.tsx +++ b/web/components/issues/issue-layouts/list/blocks-list.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; // components import { IssueBlock } from "components/issues"; // types -import { IIssue } from "types"; +import { IEstimatePoint, IIssue, IIssueLabels, IState, IUserLite } from "types"; interface Props { columnId: string; @@ -10,14 +10,14 @@ interface Props { handleIssues: (group_by: string | null, issue: IIssue, action: "update" | "delete") => void; quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode; display_properties: any; - states: any; - labels: any; - members: any; - priorities: any; + states: IState[] | null; + labels: IIssueLabels[] | null; + members: IUserLite[] | null; + estimates: IEstimatePoint[] | null; } export const IssueBlocksList: FC = (props) => { - const { columnId, issues, handleIssues, quickActions, display_properties, states, labels, members, priorities } = + const { columnId, issues, handleIssues, quickActions, display_properties, states, labels, members, estimates } = props; return ( @@ -35,7 +35,7 @@ export const IssueBlocksList: FC = (props) => { states={states} labels={labels} members={members} - priorities={priorities} + estimates={estimates} /> ))} diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index 008e625ae..114b308eb 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -2,11 +2,11 @@ import React from "react"; import { observer } from "mobx-react-lite"; // components import { ListGroupByHeaderRoot } from "./headers/group-by-root"; -import { IssueBlock } from "./block"; +import { IssueBlocksList, ListInlineCreateIssueForm } from "components/issues"; +// types +import { IEstimatePoint, IIssue, IIssueLabels, IProject, IState, IUserLite } from "types"; // constants import { getValueFromObject } from "constants/issue"; -import { IIssue } from "types"; -import { IssueBlocksList } from "./blocks-list"; export interface IGroupByList { issues: any; @@ -17,13 +17,14 @@ export interface IGroupByList { quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode; display_properties: any; is_list?: boolean; - states: any; - labels: any; - members: any; - projects: any; + states: IState[] | null; + labels: IIssueLabels[] | null; + members: IUserLite[] | null; + projects: IProject[] | null; stateGroups: any; priorities: any; - estimates: any; + enableQuickIssueCreate?: boolean; + estimates: IEstimatePoint[] | null; } const GroupByList: React.FC = observer((props) => { @@ -43,6 +44,7 @@ const GroupByList: React.FC = observer((props) => { stateGroups, priorities, estimates, + enableQuickIssueCreate, } = props; return ( @@ -72,10 +74,18 @@ const GroupByList: React.FC = observer((props) => { states={states} labels={labels} members={members} - priorities={priorities} + estimates={estimates} /> )}
+ {enableQuickIssueCreate && ( + + )}
))}
@@ -90,13 +100,14 @@ export interface IList { handleIssues: (group_by: string | null, issue: IIssue, action: "update" | "delete") => void; quickActions: (group_by: string | null, issue: IIssue) => React.ReactNode; display_properties: any; - states: any; - labels: any; - members: any; - projects: any; + states: IState[] | null; + labels: IIssueLabels[] | null; + members: IUserLite[] | null; + projects: IProject[] | null; stateGroups: any; priorities: any; - estimates: any; + enableQuickIssueCreate?: boolean; + estimates: IEstimatePoint[] | null; } export const List: React.FC = observer((props) => { @@ -113,6 +124,7 @@ export const List: React.FC = observer((props) => { stateGroups, priorities, estimates, + enableQuickIssueCreate, } = props; return ( @@ -134,6 +146,7 @@ export const List: React.FC = observer((props) => { stateGroups={stateGroups} priorities={priorities} estimates={estimates} + enableQuickIssueCreate={enableQuickIssueCreate} /> )} @@ -153,6 +166,7 @@ export const List: React.FC = observer((props) => { stateGroups={stateGroups} priorities={priorities} estimates={estimates} + enableQuickIssueCreate={enableQuickIssueCreate} /> )} @@ -172,6 +186,7 @@ export const List: React.FC = observer((props) => { stateGroups={stateGroups} priorities={priorities} estimates={estimates} + enableQuickIssueCreate={enableQuickIssueCreate} /> )} @@ -191,6 +206,7 @@ export const List: React.FC = observer((props) => { stateGroups={stateGroups} priorities={priorities} estimates={estimates} + enableQuickIssueCreate={enableQuickIssueCreate} /> )} @@ -210,6 +226,7 @@ export const List: React.FC = observer((props) => { stateGroups={stateGroups} priorities={priorities} estimates={estimates} + enableQuickIssueCreate={enableQuickIssueCreate} /> )} @@ -229,6 +246,7 @@ export const List: React.FC = observer((props) => { stateGroups={stateGroups} priorities={priorities} estimates={estimates} + enableQuickIssueCreate={enableQuickIssueCreate} /> )} @@ -248,6 +266,7 @@ export const List: React.FC = observer((props) => { stateGroups={stateGroups} priorities={priorities} estimates={estimates} + enableQuickIssueCreate={enableQuickIssueCreate} /> )} @@ -267,6 +286,7 @@ export const List: React.FC = observer((props) => { stateGroups={stateGroups} priorities={priorities} estimates={estimates} + enableQuickIssueCreate={enableQuickIssueCreate} /> )}
diff --git a/web/components/issues/issue-layouts/list/index.ts b/web/components/issues/issue-layouts/list/index.ts index 3adfe5c26..e557fe022 100644 --- a/web/components/issues/issue-layouts/list/index.ts +++ b/web/components/issues/issue-layouts/list/index.ts @@ -1,5 +1,4 @@ +export * from "./roots"; export * from "./block"; export * from "./blocks-list"; -export * from "./cycle-root"; -export * from "./module-root"; -export * from "./root"; +export * from "./inline-create-issue-form"; diff --git a/web/components/issues/issue-layouts/list/inline-create-issue-form.tsx b/web/components/issues/issue-layouts/list/inline-create-issue-form.tsx new file mode 100644 index 000000000..a6ac218e7 --- /dev/null +++ b/web/components/issues/issue-layouts/list/inline-create-issue-form.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState, useRef } from "react"; +import { useRouter } from "next/router"; +import { useForm } from "react-hook-form"; +import { Transition } from "@headlessui/react"; + +// hooks +import useToast from "hooks/use-toast"; +import useKeypress from "hooks/use-keypress"; +import useProjectDetails from "hooks/use-project-details"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; + +// store +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; + +// constants +import { createIssuePayload } from "constants/issue"; + +// types +import { IIssue } from "types"; +import { PlusIcon } from "lucide-react"; + +type Props = { + groupId?: string; + prePopulatedData?: Partial; + onSuccess?: (data: IIssue) => Promise | void; +}; + +const defaultValues: Partial = { + name: "", +}; + +const Inputs = (props: any) => { + const { register, setFocus, projectDetails } = props; + + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + return ( + <> +

{projectDetails?.identifier ?? "..."}

+ + + ); +}; + +export const ListInlineCreateIssueForm: React.FC = observer((props) => { + const { prePopulatedData, groupId } = props; + + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + // store + const { workspace: workspaceStore, quickAddIssue: quickAddStore } = useMobxStore(); + + const { projectDetails } = useProjectDetails(); + + const { + reset, + handleSubmit, + setFocus, + register, + formState: { errors, isSubmitting }, + } = useForm({ defaultValues }); + + // ref + const ref = useRef(null); + + // states + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => setIsOpen(false); + + // hooks + useKeypress("Escape", handleClose); + useOutsideClickDetector(ref, handleClose); + const { setToastAlert } = useToast(); + + // derived values + const workspaceDetail = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString()!); + + useEffect(() => { + if (!isOpen) reset({ ...defaultValues }); + }, [isOpen, reset]); + + useEffect(() => { + if (!errors) return; + + Object.keys(errors).forEach((key) => { + const error = errors[key as keyof IIssue]; + + setToastAlert({ + type: "error", + title: "Error!", + message: error?.message?.toString() || "Some error occurred. Please try again.", + }); + }); + }, [errors, setToastAlert]); + + const onSubmitHandler = async (formData: IIssue) => { + if (isSubmitting || !workspaceSlug || !projectId) return; + + // resetting the form so that user can add another issue quickly + reset({ ...defaultValues }); + + const payload = createIssuePayload(workspaceDetail!, projectDetails!, { + ...(prePopulatedData ?? {}), + ...formData, + labels_list: + formData.labels_list?.length !== 0 + ? formData.labels_list + : prePopulatedData?.labels && prePopulatedData?.labels.toString() !== "none" + ? [prePopulatedData.labels as any] + : [], + assignees_list: + formData.assignees_list?.length !== 0 + ? formData.assignees_list + : prePopulatedData?.assignees && prePopulatedData?.assignees.toString() !== "none" + ? [prePopulatedData.assignees as any] + : [], + }); + + try { + quickAddStore.createIssue( + workspaceSlug.toString(), + projectId.toString(), + { + group_id: groupId ?? null, + sub_group_id: null, + }, + payload + ); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + } catch (err: any) { + Object.keys(err || {}).forEach((key) => { + const error = err?.[key]; + const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null; + + setToastAlert({ + type: "error", + title: "Error!", + message: errorTitle || "Some error occurred. Please try again.", + }); + }); + } + }; + + return ( +
+ +
+ + +
+ + {isOpen && ( +

+ Press {"'"}Enter{"'"} to add another issue +

+ )} + + {!isOpen && ( + + )} +
+ ); +}); diff --git a/web/components/issues/issue-layouts/list/properties.tsx b/web/components/issues/issue-layouts/list/properties.tsx index 86af82656..9c70f9fdd 100644 --- a/web/components/issues/issue-layouts/list/properties.tsx +++ b/web/components/issues/issue-layouts/list/properties.tsx @@ -11,49 +11,48 @@ import { IssuePropertyDate } from "../properties/date"; // ui import { Tooltip } from "@plane/ui"; // types -import { IIssue } from "types"; +import { IEstimatePoint, IIssue, IIssueLabels, IState, IUserLite, TIssuePriorities } from "types"; export interface IKanBanProperties { columnId: string; - issue: any; - handleIssues?: (group_by: string | null, issue: IIssue) => void; + issue: IIssue; + handleIssues: (group_by: string | null, issue: IIssue) => void; display_properties: any; - states: any; - labels: any; - members: any; - priorities: any; + states: IState[] | null; + labels: IIssueLabels[] | null; + members: IUserLite[] | null; + estimates: IEstimatePoint[] | null; } export const KanBanProperties: FC = observer((props) => { - const { columnId: group_id, issue, handleIssues, display_properties, states, labels, members, priorities } = props; + const { columnId: group_id, issue, handleIssues, display_properties, states, labels, members, estimates } = props; - const handleState = (id: string) => { - if (handleIssues) handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, state: id }); + const handleState = (state: IState) => { + handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, state: state.id }); }; - const handlePriority = (id: string) => { - if (handleIssues) handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, priority: id }); + const handlePriority = (value: TIssuePriorities) => { + handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, priority: value }); }; const handleLabel = (ids: string[]) => { - if (handleIssues) handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, labels: ids }); + handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, labels_list: ids }); }; const handleAssignee = (ids: string[]) => { - if (handleIssues) handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, assignees: ids }); + handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, assignees_list: ids }); }; const handleStartDate = (date: string) => { - if (handleIssues) handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, start_date: date }); + handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, start_date: date }); }; const handleTargetDate = (date: string) => { - if (handleIssues) handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, target_date: date }); + handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, target_date: date }); }; - const handleEstimate = (id: string) => { - if (handleIssues) - handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, estimate_point: id }); + const handleEstimate = (value: number | null) => { + handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, estimate_point: value }); }; return ( @@ -62,22 +61,21 @@ export const KanBanProperties: FC = observer((props) => { {/* state */} {display_properties && display_properties?.state && states && ( handleState(id)} + value={issue?.state_detail || null} + hideDropdownArrow={true} + onChange={handleState} disabled={false} - list={states} + states={states} /> )} {/* priority */} - {display_properties && display_properties?.priority && priorities && ( + {display_properties && display_properties?.priority && ( handlePriority(id)} + onChange={handlePriority} disabled={false} - list={priorities} + hideDropdownArrow={true} /> )} @@ -85,10 +83,10 @@ export const KanBanProperties: FC = observer((props) => { {display_properties && display_properties?.labels && labels && ( handleLabel(ids)} + onChange={handleLabel} + labels={labels} disabled={false} - list={labels} + hideDropdownArrow={true} /> )} @@ -96,10 +94,10 @@ export const KanBanProperties: FC = observer((props) => { {display_properties && display_properties?.assignee && members && ( handleAssignee(ids)} + hideDropdownArrow={true} + onChange={handleAssignee} disabled={false} - list={members} + members={members} /> )} @@ -109,7 +107,7 @@ export const KanBanProperties: FC = observer((props) => { value={issue?.start_date || null} onChange={(date: string) => handleStartDate(date)} disabled={false} - placeHolder={`Start date`} + placeHolder="Start date" /> )} @@ -119,31 +117,28 @@ export const KanBanProperties: FC = observer((props) => { value={issue?.target_date || null} onChange={(date: string) => handleTargetDate(date)} disabled={false} - placeHolder={`Target date`} + placeHolder="Target date" /> )} {/* estimates */} {display_properties && display_properties?.estimate && ( handleEstimate(id)} + value={issue?.estimate_point || null} + estimatePoints={estimates} + hideDropdownArrow={true} + onChange={handleEstimate} disabled={false} - workspaceSlug={issue?.workspace_detail?.slug || null} - projectId={issue?.project_detail?.id || null} /> )} {/* extra render properties */} {/* sub-issues */} {display_properties && display_properties?.sub_issue_count && ( - -
-
- -
-
{issue.sub_issues_count}
+ +
+ +
{issue.sub_issues_count}
)} @@ -151,11 +146,9 @@ export const KanBanProperties: FC = observer((props) => { {/* attachments */} {display_properties && display_properties?.attachment_count && ( -
-
- -
-
{issue.attachment_count}
+
+ +
{issue.attachment_count}
)} @@ -163,11 +156,9 @@ export const KanBanProperties: FC = observer((props) => { {/* link */} {display_properties && display_properties?.link && ( -
-
- -
-
{issue.link_count}
+
+ +
{issue.link_count}
)} diff --git a/web/components/issues/issue-layouts/list/cycle-root.tsx b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx similarity index 80% rename from web/components/issues/issue-layouts/list/cycle-root.tsx rename to web/components/issues/issue-layouts/list/roots/cycle-root.tsx index 511336531..771186c84 100644 --- a/web/components/issues/issue-layouts/list/cycle-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx @@ -4,8 +4,10 @@ import { observer } from "mobx-react-lite"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // components -import { List } from "./default"; +import { List } from "../default"; import { CycleIssueQuickActions } from "components/issues"; +// helpers +import { orderArrayBy } from "helpers/array.helper"; // types import { IIssue } from "types"; // constants @@ -15,7 +17,7 @@ export interface ICycleListLayout {} export const CycleListLayout: React.FC = observer(() => { const router = useRouter(); - const { workspaceSlug, cycleId } = router.query; + const { workspaceSlug, projectId, cycleId } = router.query; const { project: projectStore, @@ -52,13 +54,18 @@ export const CycleListLayout: React.FC = observer(() => { [cycleIssueStore, issueDetailStore, cycleId, workspaceSlug] ); + const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : null; + const states = projectStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; const labels = projectStore?.projectLabels || null; const members = projectStore?.projectMembers || null; const stateGroups = ISSUE_STATE_GROUPS || null; - const projects = projectStore?.projectStates || null; - const estimates = null; + const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null; + const estimates = + projectDetails?.estimate !== null + ? projectStore.projectEstimates?.find((e) => e.id === projectDetails?.estimate) || null + : null; return (
@@ -79,9 +86,9 @@ export const CycleListLayout: React.FC = observer(() => { stateGroups={stateGroups} priorities={priorities} labels={labels} - members={members} + members={members?.map((m) => m.member) ?? null} projects={projects} - estimates={estimates} + estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null} />
); diff --git a/web/components/issues/issue-layouts/list/roots/index.ts b/web/components/issues/issue-layouts/list/roots/index.ts new file mode 100644 index 000000000..139c09a7a --- /dev/null +++ b/web/components/issues/issue-layouts/list/roots/index.ts @@ -0,0 +1,5 @@ +export * from "./cycle-root"; +export * from "./module-root"; +export * from "./profile-issues-root"; +export * from "./project-root"; +export * from "./project-view-root"; diff --git a/web/components/issues/issue-layouts/list/module-root.tsx b/web/components/issues/issue-layouts/list/roots/module-root.tsx similarity index 80% rename from web/components/issues/issue-layouts/list/module-root.tsx rename to web/components/issues/issue-layouts/list/roots/module-root.tsx index 485e4e908..daa12e64a 100644 --- a/web/components/issues/issue-layouts/list/module-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/module-root.tsx @@ -4,8 +4,10 @@ import { observer } from "mobx-react-lite"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // components -import { List } from "./default"; +import { List } from "../default"; import { ModuleIssueQuickActions } from "components/issues"; +// helpers +import { orderArrayBy } from "helpers/array.helper"; // types import { IIssue } from "types"; // constants @@ -15,7 +17,7 @@ export interface IModuleListLayout {} export const ModuleListLayout: React.FC = observer(() => { const router = useRouter(); - const { workspaceSlug, moduleId } = router.query; + const { workspaceSlug, projectId, moduleId } = router.query; const { project: projectStore, @@ -52,13 +54,18 @@ export const ModuleListLayout: React.FC = observer(() => { [moduleIssueStore, issueDetailStore, moduleId, workspaceSlug] ); + const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : null; + const states = projectStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; const labels = projectStore?.projectLabels || null; const members = projectStore?.projectMembers || null; const stateGroups = ISSUE_STATE_GROUPS || null; - const projects = projectStore?.projectStates || null; - const estimates = null; + const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null; + const estimates = + projectDetails?.estimate !== null + ? projectStore.projectEstimates?.find((e) => e.id === projectDetails?.estimate) || null + : null; return (
@@ -79,9 +86,9 @@ export const ModuleListLayout: React.FC = observer(() => { stateGroups={stateGroups} priorities={priorities} labels={labels} - members={members} + members={members?.map((m) => m.member) ?? null} projects={projects} - estimates={estimates} + estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null} />
); diff --git a/web/components/issues/issue-layouts/list/profile-issues-root.tsx b/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx similarity index 95% rename from web/components/issues/issue-layouts/list/profile-issues-root.tsx rename to web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx index b1fb86c6a..9e4937ffd 100644 --- a/web/components/issues/issue-layouts/list/profile-issues-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // components -import { List } from "./default"; +import { List } from "../default"; import { ProjectIssueQuickActions } from "components/issues"; // types import { IIssue } from "types"; @@ -50,7 +50,6 @@ export const ProfileIssuesListLayout: FC = observer(() => { const members = projectStore?.projectMembers || null; const stateGroups = ISSUE_STATE_GROUPS || null; const projects = projectStore?.workspaceProjects || null; - const estimates = null; return (
@@ -70,9 +69,9 @@ export const ProfileIssuesListLayout: FC = observer(() => { stateGroups={stateGroups} priorities={priorities} labels={labels} - members={members} + members={members?.map((m) => m.member) ?? null} projects={projects} - estimates={estimates} + estimates={null} />
); diff --git a/web/components/issues/issue-layouts/list/root.tsx b/web/components/issues/issue-layouts/list/roots/project-root.tsx similarity index 76% rename from web/components/issues/issue-layouts/list/root.tsx rename to web/components/issues/issue-layouts/list/roots/project-root.tsx index a5b1baa64..47c1753c6 100644 --- a/web/components/issues/issue-layouts/list/root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-root.tsx @@ -4,8 +4,10 @@ import { observer } from "mobx-react-lite"; // hooks import { useMobxStore } from "lib/mobx/store-provider"; // components -import { List } from "./default"; +import { List } from "../default"; import { ProjectIssueQuickActions } from "components/issues"; +// helpers +import { orderArrayBy } from "helpers/array.helper"; // types import { IIssue } from "types"; // constants @@ -13,7 +15,7 @@ import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES } from "constants/issue"; export const ListLayout: FC = observer(() => { const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; const { project: projectStore, @@ -41,13 +43,18 @@ export const ListLayout: FC = observer(() => { [issueStore, issueDetailStore, workspaceSlug] ); + const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : null; + const states = projectStore?.projectStates || null; const priorities = ISSUE_PRIORITIES || null; const labels = projectStore?.projectLabels || null; const members = projectStore?.projectMembers || null; const stateGroups = ISSUE_STATE_GROUPS || null; - const projects = projectStore?.projectStates || null; - const estimates = null; + const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null; + const estimates = + projectDetails?.estimate !== null + ? projectStore.projectEstimates?.find((e) => e.id === projectDetails?.estimate) || null + : null; return (
@@ -67,9 +74,10 @@ export const ListLayout: FC = observer(() => { stateGroups={stateGroups} priorities={priorities} labels={labels} - members={members} + members={members?.map((m) => m.member) ?? null} projects={projects} - estimates={estimates} + enableQuickIssueCreate + estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null} />
); diff --git a/web/components/issues/issue-layouts/list/view-root.tsx b/web/components/issues/issue-layouts/list/roots/project-view-root.tsx similarity index 94% rename from web/components/issues/issue-layouts/list/view-root.tsx rename to web/components/issues/issue-layouts/list/roots/project-view-root.tsx index aa7ab563a..85b8177b3 100644 --- a/web/components/issues/issue-layouts/list/view-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-view-root.tsx @@ -1,7 +1,7 @@ import React from "react"; import { observer } from "mobx-react-lite"; // components -import { List } from "./default"; +import { List } from "../default"; // store import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; @@ -10,7 +10,7 @@ import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES } from "constants/issue"; export interface IViewListLayout {} -export const ViewListLayout: React.FC = observer(() => { +export const ProjectViewListLayout: React.FC = observer(() => { const { project: projectStore, issue: issueStore, issueFilter: issueFilterStore }: RootStore = useMobxStore(); const issues = issueStore?.getIssues; diff --git a/web/components/issues/issue-layouts/properties/assignee.tsx b/web/components/issues/issue-layouts/properties/assignee.tsx index a235ec5a2..6a209f3b6 100644 --- a/web/components/issues/issue-layouts/properties/assignee.tsx +++ b/web/components/issues/issue-layouts/properties/assignee.tsx @@ -1,252 +1,28 @@ -import { FC, useRef, useState } from "react"; -import { Combobox } from "@headlessui/react"; -import { ChevronDown, Search, X, Check } from "lucide-react"; import { observer } from "mobx-react-lite"; // components -import { Tooltip } from "@plane/ui"; -// hooks -import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; - -interface IFiltersOption { - id: string; - title: string; - avatar: string; -} +import { MembersSelect } from "components/project"; +// types +import { IUserLite } from "types"; export interface IIssuePropertyAssignee { - value?: any; - onChange?: (id: any, data: any) => void; + value: string[]; + onChange: (data: string[]) => void; + members: IUserLite[] | null; disabled?: boolean; - list?: any; - - className?: string; - buttonClassName?: string; - optionsClassName?: string; - dropdownArrow?: boolean; + hideDropdownArrow?: boolean; } -export const IssuePropertyAssignee: FC = observer((props) => { - const { value, onChange, disabled, list, className, buttonClassName, optionsClassName, dropdownArrow = true } = props; - - const dropdownBtn = useRef(null); - const dropdownOptions = useRef(null); - - const [isOpen, setIsOpen] = useState(false); - const [search, setSearch] = useState(""); - - const options: IFiltersOption[] | [] = - (list && - list?.length > 0 && - list.map((_member: any) => ({ - id: _member?.member?.id, - title: _member?.member?.display_name, - avatar: _member?.member?.avatar && _member?.member?.avatar !== "" ? _member?.member?.avatar : null, - }))) || - []; - - useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions); - - const selectedOption: IFiltersOption[] = - (value && value?.length > 0 && options.filter((_member: IFiltersOption) => value.includes(_member.id))) || []; - - const filteredOptions: IFiltersOption[] = - search === "" - ? options && options.length > 0 - ? options - : [] - : options && options.length > 0 - ? options.filter((_member: IFiltersOption) => - _member.title.toLowerCase().replace(/\s+/g, "").includes(search.toLowerCase().replace(/\s+/g, "")) - ) - : []; - - const assigneeRenderLength = 5; +export const IssuePropertyAssignee: React.FC = observer((props) => { + const { value, onChange, members, disabled = false, hideDropdownArrow = false } = props; return ( - _member.id) as string[]} - onChange={(data: string[]) => { - if (onChange && selectedOption) onChange(data, selectedOption); - }} + - {({ open }: { open: boolean }) => { - if (open) { - if (!isOpen) setIsOpen(true); - } else if (isOpen) setIsOpen(false); - - return ( - <> - - {selectedOption && selectedOption?.length > 0 ? ( - <> - {selectedOption?.length > 1 ? ( - _label.title) || []).join(", ")} - > -
- {selectedOption.slice(0, assigneeRenderLength).map((_assignee) => ( -
- {_assignee && _assignee.avatar ? ( - {_assignee.title} - ) : ( - _assignee.title[0] - )} -
- ))} - {selectedOption.length > assigneeRenderLength && ( -
- +{selectedOption?.length - assigneeRenderLength} -
- )} -
-
- ) : ( - _label.title) || []).join(", ")} - > -
-
- {selectedOption[0] && selectedOption[0].avatar ? ( - {selectedOption[0].title} - ) : ( -
- {selectedOption[0].title[0]} -
- )} -
-
{selectedOption[0].title}
-
-
- )} - - ) : ( - -
Select Assignees
-
- )} - - {dropdownArrow && !disabled && ( -
- -
- )} -
- -
- - {options && options.length > 0 ? ( - <> -
-
- -
- -
- setSearch(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
- - {search && search.length > 0 && ( -
setSearch("")} - className="flex-shrink-0 flex justify-center items-center w-[16px] h-[16px] rounded-sm cursor-pointer hover:bg-custom-background-80" - > - -
- )} -
- -
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `cursor-pointer select-none truncate rounded px-1 py-1.5 ${ - active || (value && value.length > 0 && value.includes(option?.id)) - ? "bg-custom-background-80" - : "" - } ${ - value && value.length > 0 && value.includes(option?.id) - ? "text-custom-text-100" - : "text-custom-text-200" - }` - } - > -
-
- {option && option.avatar ? ( - {option.title} - ) : ( -
- {option.title[0]} -
- )} -
-
{option.title}
- {value && value.length > 0 && value.includes(option?.id) && ( -
- -
- )} -
-
- )) - ) : ( - -

No matching results

-
- ) - ) : ( -

Loading...

- )} -
- - ) : ( -

No options available.

- )} -
-
- - ); - }} -
+ hideDropdownArrow={hideDropdownArrow} + multiple + /> ); }); diff --git a/web/components/issues/issue-layouts/properties/date.tsx b/web/components/issues/issue-layouts/properties/date.tsx index 48b6de507..dbcbc0eac 100644 --- a/web/components/issues/issue-layouts/properties/date.tsx +++ b/web/components/issues/issue-layouts/properties/date.tsx @@ -15,85 +15,81 @@ import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; import { renderDateFormat } from "helpers/date-time.helper"; export interface IIssuePropertyDate { - value?: any; - onChange?: (date: any) => void; + value: any; + onChange: (date: any) => void; disabled?: boolean; placeHolder?: string; } -export const IssuePropertyDate: React.FC = observer( - ({ value, onChange, disabled, placeHolder }) => { - const dropdownBtn = React.useRef(null); - const dropdownOptions = React.useRef(null); +export const IssuePropertyDate: React.FC = observer((props) => { + const { value, onChange, disabled, placeHolder } = props; - const [isOpen, setIsOpen] = React.useState(false); + const dropdownBtn = React.useRef(null); + const dropdownOptions = React.useRef(null); - useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions); + const [isOpen, setIsOpen] = React.useState(false); - return ( - - {({ open }) => { - if (open) { - if (!isOpen) setIsOpen(true); - } else if (isOpen) setIsOpen(false); + useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions); - return ( - <> - - -
-
- -
- {value ? ( - <> -
{value}
-
{ - if (onChange) onChange(null); - }} - > - -
- - ) : ( -
{placeHolder ? placeHolder : `Select date`}
- )} -
-
-
+ return ( + + {({ open }) => { + if (open) { + if (!isOpen) setIsOpen(true); + } else if (isOpen) setIsOpen(false); -
- - {({ close }) => ( - { - if (onChange && val) { - onChange(renderDateFormat(val)); - close(); - } - }} - dateFormat="dd-MM-yyyy" - calendarClassName="h-full" - inline - /> + return ( + <> + + +
+ + {value && ( + <> +
{value}
+
{ + if (onChange) onChange(null); + }} + > + +
+ )} - -
- - ); - }} - - ); - } -); +
+ + + +
+ + {({ close }) => ( + { + if (onChange && val) { + onChange(renderDateFormat(val)); + close(); + } + }} + dateFormat="dd-MM-yyyy" + calendarClassName="h-full" + inline + /> + )} + +
+ + ); + }} +
+ ); +}); diff --git a/web/components/issues/issue-layouts/properties/estimates.tsx b/web/components/issues/issue-layouts/properties/estimates.tsx index 80b3d9615..83de934cb 100644 --- a/web/components/issues/issue-layouts/properties/estimates.tsx +++ b/web/components/issues/issue-layouts/properties/estimates.tsx @@ -1,217 +1,28 @@ -import React from "react"; -// headless ui -import { Combobox } from "@headlessui/react"; -// lucide icons -import { ChevronDown, Search, X, Check, Triangle } from "lucide-react"; -// mobx import { observer } from "mobx-react-lite"; // components -import { Tooltip } from "@plane/ui"; -// hooks -import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; -// mobx -import { useMobxStore } from "lib/mobx/store-provider"; -import { RootStore } from "store/root"; - -interface IFiltersOption { - id: string; - title: string; - key: string; -} +import { EstimateSelect } from "components/estimates"; +// types +import { IEstimatePoint } from "types"; export interface IIssuePropertyEstimates { - value?: any; - onChange?: (id: any) => void; + value: number | null; + onChange: (value: number | null) => void; + estimatePoints: IEstimatePoint[] | null; disabled?: boolean; - - workspaceSlug?: string; - projectId?: string; - - className?: string; - buttonClassName?: string; - optionsClassName?: string; - dropdownArrow?: boolean; + hideDropdownArrow?: boolean; } -export const IssuePropertyEstimates: React.FC = observer( - ({ - value, - onChange, - disabled, +export const IssuePropertyEstimates: React.FC = observer((props) => { + const { value, onChange, estimatePoints, disabled, hideDropdownArrow = false } = props; - workspaceSlug, - projectId, - - className, - buttonClassName, - optionsClassName, - dropdownArrow = true, - }) => { - const { project: projectStore }: RootStore = useMobxStore(); - - const dropdownBtn = React.useRef(null); - const dropdownOptions = React.useRef(null); - - const [isOpen, setIsOpen] = React.useState(false); - const [search, setSearch] = React.useState(""); - - const projectDetail = - (workspaceSlug && projectId && projectStore?.getProjectById(workspaceSlug, projectId)) || null; - const projectEstimateId = (projectDetail && projectDetail?.estimate) || null; - const estimates = (projectEstimateId && projectStore?.getProjectEstimateById(projectEstimateId)) || null; - - const options: IFiltersOption[] | [] = - (estimates && - estimates.points && - estimates.points.length > 0 && - estimates.points.map((_estimate) => ({ - id: _estimate?.id, - title: _estimate?.value, - key: _estimate?.key.toString(), - }))) || - []; - - useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions); - - const selectedOption: IFiltersOption | null | undefined = - (value && options.find((_estimate: IFiltersOption) => _estimate.key === value)) || null; - - const filteredOptions: IFiltersOption[] = - search === "" - ? options && options.length > 0 - ? options - : [] - : options && options.length > 0 - ? options.filter((_estimate: IFiltersOption) => - _estimate.title.toLowerCase().replace(/\s+/g, "").includes(search.toLowerCase().replace(/\s+/g, "")) - ) - : []; - - return ( - { - if (onChange) onChange(data); - }} - disabled={disabled} - > - {({ open }: { open: boolean }) => { - if (open) { - if (!isOpen) setIsOpen(true); - } else if (isOpen) setIsOpen(false); - - return ( - <> - - {selectedOption ? ( - -
-
- -
-
{selectedOption?.title}
-
-
- ) : ( - -
Select Estimates
-
- )} - - {dropdownArrow && !disabled && ( -
- -
- )} -
- -
- - {options && options.length > 0 ? ( - <> -
-
- -
- -
- setSearch(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
- - {search && search.length > 0 && ( -
setSearch("")} - className="flex-shrink-0 flex justify-center items-center w-[16px] h-[16px] rounded-sm cursor-pointer hover:bg-custom-background-80" - > - -
- )} -
- -
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `cursor-pointer select-none truncate rounded px-1 py-1.5 ${ - active || selected ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( -
-
- -
-
{option.title}
- {selected && ( -
- -
- )} -
- )} -
- )) - ) : ( - -

No matching results

-
- ) - ) : ( -

Loading...

- )} -
- - ) : ( -

No options available.

- )} -
-
- - ); - }} -
- ); - } -); + return ( + + ); +}); diff --git a/web/components/issues/issue-layouts/properties/labels.tsx b/web/components/issues/issue-layouts/properties/labels.tsx index bc9930c72..dcc884b19 100644 --- a/web/components/issues/issue-layouts/properties/labels.tsx +++ b/web/components/issues/issue-layouts/properties/labels.tsx @@ -1,230 +1,28 @@ -import { FC, useRef, useState } from "react"; -import { Combobox } from "@headlessui/react"; -import { ChevronDown, Search, X, Check } from "lucide-react"; import { observer } from "mobx-react-lite"; // components -import { Tooltip } from "@plane/ui"; -// hooks -import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; - -interface IFiltersOption { - id: string; - title: string; - color: string | null; -} +import { LabelSelect } from "components/project"; +// types +import { IIssueLabels } from "types"; export interface IIssuePropertyLabels { - value?: any; - onChange?: (id: any, data: any) => void; + value: string[]; + onChange: (data: string[]) => void; + labels: IIssueLabels[] | null; disabled?: boolean; - list?: any; - - className?: string; - buttonClassName?: string; - optionsClassName?: string; - dropdownArrow?: boolean; + hideDropdownArrow?: boolean; } -export const IssuePropertyLabels: FC = observer((props) => { - const { - value, - onChange, - disabled, - list, - - className, - buttonClassName, - optionsClassName, - dropdownArrow = true, - } = props; - - const dropdownBtn = useRef(null); - const dropdownOptions = useRef(null); - - const [isOpen, setIsOpen] = useState(false); - const [search, setSearch] = useState(""); - - const options: IFiltersOption[] | [] = - (list && - list?.length > 0 && - list.map((_label: any) => ({ - id: _label?.id, - title: _label?.name, - color: _label?.color || null, - }))) || - []; - - useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions); - - const selectedOption: IFiltersOption[] = - (value && value?.length > 0 && options.filter((_label: IFiltersOption) => value.includes(_label.id))) || []; - - const filteredOptions: IFiltersOption[] = - search === "" - ? options && options.length > 0 - ? options - : [] - : options && options.length > 0 - ? options.filter((_label: IFiltersOption) => - _label.title.toLowerCase().replace(/\s+/g, "").includes(search.toLowerCase().replace(/\s+/g, "")) - ) - : []; +export const IssuePropertyLabels: React.FC = observer((props) => { + const { value, onChange, labels, disabled, hideDropdownArrow = false } = props; return ( - _label.id) as string[]} - onChange={(data: string[]) => { - if (onChange && selectedOption) onChange(data, selectedOption); - }} + - {({ open }: { open: boolean }) => { - if (open) { - if (!isOpen) setIsOpen(true); - } else if (isOpen) setIsOpen(false); - - return ( - <> - - {selectedOption && selectedOption?.length > 0 ? ( - <> - {selectedOption?.length === 1 ? ( - _label.title) || []).join(", ")} - > -
-
-
{selectedOption[0]?.title}
-
- - ) : ( - _label.title) || []).join(", ")} - > -
-
-
{selectedOption?.length} Labels
-
- - )} - - ) : ( - -
Select Labels
-
- )} - - {dropdownArrow && !disabled && ( -
- -
- )} - - -
- - {options && options.length > 0 ? ( - <> -
-
- -
- -
- setSearch(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
- - {search && search.length > 0 && ( -
setSearch("")} - className="flex-shrink-0 flex justify-center items-center w-[16px] h-[16px] rounded-sm cursor-pointer hover:bg-custom-background-80" - > - -
- )} -
- -
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `cursor-pointer select-none truncate rounded px-1 py-1.5 ${ - active || (value && value.length > 0 && value.includes(option?.id)) - ? "bg-custom-background-80" - : "" - } ${ - value && value.length > 0 && value.includes(option?.id) - ? "text-custom-text-100" - : "text-custom-text-200" - }` - } - > -
-
-
{option.title}
- {value && value.length > 0 && value.includes(option?.id) && ( -
- -
- )} -
- - )) - ) : ( - -

No matching results

-
- ) - ) : ( -

Loading...

- )} -
- - ) : ( -

No options available.

- )} - -
- - ); - }} - + hideDropdownArrow={hideDropdownArrow} + /> ); }); diff --git a/web/components/issues/issue-layouts/properties/priority.tsx b/web/components/issues/issue-layouts/properties/priority.tsx index 37404c11b..cbf6602eb 100644 --- a/web/components/issues/issue-layouts/properties/priority.tsx +++ b/web/components/issues/issue-layouts/properties/priority.tsx @@ -1,223 +1,25 @@ -import { FC, useRef, useState } from "react"; -import { Combobox } from "@headlessui/react"; -import { ChevronDown, Search, X, Check, AlertCircle, SignalHigh, SignalMedium, SignalLow, Ban } from "lucide-react"; +import { PrioritySelect } from "components/project"; import { observer } from "mobx-react-lite"; -// components -import { Tooltip } from "@plane/ui"; -// hooks -import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; - -interface IFiltersOption { - id: string; - title: string; -} +// types +import { TIssuePriorities } from "types"; export interface IIssuePropertyPriority { - value?: any; - onChange?: (id: any, data: IFiltersOption) => void; + value: TIssuePriorities; + onChange: (value: TIssuePriorities) => void; disabled?: boolean; - list?: any; - - className?: string; - buttonClassName?: string; - optionsClassName?: string; - dropdownArrow?: boolean; + hideDropdownArrow?: boolean; } -const Icon = ({ priority }: any) => ( -
- {priority === "urgent" ? ( -
- -
- ) : priority === "high" ? ( -
- -
- ) : priority === "medium" ? ( -
- -
- ) : priority === "low" ? ( -
- -
- ) : ( -
- -
- )} -
-); - -export const IssuePropertyPriority: FC = observer((props) => { - const { - value, - onChange, - disabled, - list, - - className, - buttonClassName, - optionsClassName, - dropdownArrow = true, - } = props; - - const dropdownBtn = useRef(null); - const dropdownOptions = useRef(null); - - const [isOpen, setIsOpen] = useState(false); - const [search, setSearch] = useState(""); - - const options: IFiltersOption[] | [] = - (list && - list?.length > 0 && - list.map((_priority: any) => ({ - id: _priority?.key, - title: _priority?.title, - }))) || - []; - - useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions); - - const selectedOption: IFiltersOption | null | undefined = - (value && options.find((_priority: IFiltersOption) => _priority.id === value)) || null; - - const filteredOptions: IFiltersOption[] = - search === "" - ? options && options.length > 0 - ? options - : [] - : options && options.length > 0 - ? options.filter((_priority: IFiltersOption) => - _priority.title.toLowerCase().replace(/\s+/g, "").includes(search.toLowerCase().replace(/\s+/g, "")) - ) - : []; +export const IssuePropertyPriority: React.FC = observer((props) => { + const { value, onChange, disabled, hideDropdownArrow = false } = props; return ( - { - if (onChange && selectedOption) onChange(data, selectedOption); - }} + - {({ open }: { open: boolean }) => { - if (open) { - if (!isOpen) setIsOpen(true); - } else if (isOpen) setIsOpen(false); - - return ( - <> - - {selectedOption ? ( - -
-
- -
-
{selectedOption?.title}
-
-
- ) : ( - -
Select Priority
-
- )} - - {dropdownArrow && !disabled && ( -
- -
- )} -
- -
- - {options && options.length > 0 ? ( - <> -
-
- -
- -
- setSearch(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
- - {search && search.length > 0 && ( -
setSearch("")} - className="flex-shrink-0 flex justify-center items-center w-[16px] h-[16px] rounded-sm cursor-pointer hover:bg-custom-background-80" - > - -
- )} -
- -
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `cursor-pointer select-none truncate rounded px-1 py-1.5 ${ - active || selected ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( -
-
- -
-
{option.title}
- {selected && ( -
- -
- )} -
- )} -
- )) - ) : ( - -

No matching results

-
- ) - ) : ( -

Loading...

- )} -
- - ) : ( -

No options available.

- )} -
-
- - ); - }} -
+ hideDropdownArrow={hideDropdownArrow} + /> ); }); diff --git a/web/components/issues/issue-layouts/properties/state.tsx b/web/components/issues/issue-layouts/properties/state.tsx index 05cb375ad..9264b3084 100644 --- a/web/components/issues/issue-layouts/properties/state.tsx +++ b/web/components/issues/issue-layouts/properties/state.tsx @@ -1,214 +1,28 @@ -import { FC, useRef, useState } from "react"; -import { Combobox } from "@headlessui/react"; -import { ChevronDown, Search, X, Check } from "lucide-react"; import { observer } from "mobx-react-lite"; // components -import { Tooltip, StateGroupIcon } from "@plane/ui"; -// hooks -import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; - +import { StateSelect } from "components/states"; // types import { IState } from "types"; -interface IFiltersOption { - id: string; - title: string; - group: string; - color: string | null; -} - export interface IIssuePropertyState { - value?: any; - onChange?: (id: any, data: IFiltersOption) => void; + value: IState; + onChange: (state: IState) => void; + states: IState[] | null; disabled?: boolean; - list?: any; - - className?: string; - buttonClassName?: string; - optionsClassName?: string; - dropdownArrow?: boolean; + hideDropdownArrow?: boolean; } -export const IssuePropertyState: FC = observer((props) => { - const { - value, - onChange, - disabled, - list, - - className, - buttonClassName, - optionsClassName, - dropdownArrow = true, - } = props; - - const dropdownBtn = useRef(null); - const dropdownOptions = useRef(null); - - const [isOpen, setIsOpen] = useState(false); - const [search, setSearch] = useState(""); - - const options: IFiltersOption[] | [] = - (list && - list?.length > 0 && - list.map((_state: IState) => ({ - id: _state?.id, - title: _state?.name, - group: _state?.group, - color: _state?.color || null, - }))) || - []; - - useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions); - - const selectedOption: IFiltersOption | null | undefined = - (value && options.find((_state: IFiltersOption) => _state.id === value)) || null; - - const filteredOptions: IFiltersOption[] = - search === "" - ? options && options.length > 0 - ? options - : [] - : options && options.length > 0 - ? options.filter((_state: IFiltersOption) => - _state.title.toLowerCase().replace(/\s+/g, "").includes(search.toLowerCase().replace(/\s+/g, "")) - ) - : []; +export const IssuePropertyState: React.FC = observer((props) => { + const { value, onChange, states, disabled, hideDropdownArrow = false } = props; return ( - { - if (onChange && selectedOption) onChange(data, selectedOption); - }} + - {({ open }: { open: boolean }) => { - if (open) { - if (!isOpen) setIsOpen(true); - } else if (isOpen) setIsOpen(false); - - return ( - <> - - {selectedOption ? ( - -
-
- -
-
{selectedOption?.title}
-
-
- ) : ( - -
Select State
-
- )} - - {dropdownArrow && !disabled && ( -
- -
- )} -
- -
- - {options && options.length > 0 ? ( - <> -
-
- -
- -
- setSearch(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
- - {search && search.length > 0 && ( -
setSearch("")} - className="flex-shrink-0 flex justify-center items-center w-[16px] h-[16px] rounded-sm cursor-pointer hover:bg-custom-background-80" - > - -
- )} -
- -
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `cursor-pointer select-none truncate rounded px-1 py-1.5 ${ - active || selected ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( -
-
- -
-
{option.title}
- {selected && ( -
- -
- )} -
- )} -
- )) - ) : ( - -

No matching results

-
- ) - ) : ( -

Loading...

- )} -
- - ) : ( -

No options available.

- )} -
-
- - ); - }} -
+ hideDropdownArrow={hideDropdownArrow} + /> ); }); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx index 7873ebe02..81f45d04f 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx @@ -4,6 +4,8 @@ import React from "react"; import { StateSelect } from "components/states"; // hooks import useSubIssue from "hooks/use-sub-issue"; +// helpers +import { getStatesList } from "helpers/state.helper"; // types import { IIssue, IStateResponse } from "types"; @@ -22,12 +24,14 @@ export const SpreadsheetStateColumn: React.FC = (props) => { const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + const statesList = getStatesList(states); + return ( <> onChange({ state: data.id, state_detail: data })} - stateGroups={states} + states={statesList} buttonClassName="!shadow-none !border-0" hideDropdownArrow disabled={disabled} diff --git a/web/components/issues/issue-layouts/spreadsheet/index.ts b/web/components/issues/issue-layouts/spreadsheet/index.ts index 64b02a766..5b14a2dab 100644 --- a/web/components/issues/issue-layouts/spreadsheet/index.ts +++ b/web/components/issues/issue-layouts/spreadsheet/index.ts @@ -2,3 +2,4 @@ export * from "./columns"; export * from "./roots"; export * from "./spreadsheet-column"; export * from "./spreadsheet-view"; +export * from "./inline-create-issue-form"; diff --git a/web/components/issues/issue-layouts/spreadsheet/inline-create-issue-form.tsx b/web/components/issues/issue-layouts/spreadsheet/inline-create-issue-form.tsx new file mode 100644 index 000000000..20105a67e --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/inline-create-issue-form.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState, useRef } from "react"; +import { useRouter } from "next/router"; +import { useForm } from "react-hook-form"; +import { Transition } from "@headlessui/react"; + +// hooks +import useToast from "hooks/use-toast"; +import useKeypress from "hooks/use-keypress"; +import useProjectDetails from "hooks/use-project-details"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; + +// store +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; + +// constants +import { createIssuePayload } from "constants/issue"; + +// types +import { IIssue } from "types"; +import { PlusIcon } from "lucide-react"; + +type Props = { + groupId?: string; + prePopulatedData?: Partial; + onSuccess?: (data: IIssue) => Promise | void; +}; + +const defaultValues: Partial = { + name: "", +}; + +const Inputs = (props: any) => { + const { register, setFocus, projectDetails } = props; + + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + return ( + <> +

{projectDetails?.identifier ?? "..."}

+ + + ); +}; + +export const SpreadsheetInlineCreateIssueForm: React.FC = observer((props) => { + const { prePopulatedData, groupId } = props; + + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + // store + const { workspace: workspaceStore, quickAddIssue: quickAddStore } = useMobxStore(); + + const { projectDetails } = useProjectDetails(); + + const { + reset, + handleSubmit, + setFocus, + register, + formState: { errors, isSubmitting }, + } = useForm({ defaultValues }); + + // ref + const ref = useRef(null); + + // states + const [isOpen, setIsOpen] = useState(false); + + const handleClose = () => setIsOpen(false); + + // hooks + useKeypress("Escape", handleClose); + useOutsideClickDetector(ref, handleClose); + const { setToastAlert } = useToast(); + + // derived values + const workspaceDetail = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString()!); + + useEffect(() => { + setFocus("name"); + }, [setFocus, isOpen]); + + useEffect(() => { + if (!isOpen) reset({ ...defaultValues }); + }, [isOpen, reset]); + + useEffect(() => { + if (!errors) return; + + Object.keys(errors).forEach((key) => { + const error = errors[key as keyof IIssue]; + + setToastAlert({ + type: "error", + title: "Error!", + message: error?.message?.toString() || "Some error occurred. Please try again.", + }); + }); + }, [errors, setToastAlert]); + + const onSubmitHandler = async (formData: IIssue) => { + if (isSubmitting || !workspaceSlug || !projectId) return; + + // resetting the form so that user can add another issue quickly + reset({ ...defaultValues }); + + const payload = createIssuePayload(workspaceDetail!, projectDetails!, { + ...(prePopulatedData ?? {}), + ...formData, + labels_list: + formData.labels_list && formData.labels_list?.length !== 0 + ? formData.labels_list + : prePopulatedData?.labels && prePopulatedData?.labels.toString() !== "none" + ? [prePopulatedData.labels as any] + : [], + assignees_list: + formData.assignees_list && formData.assignees_list?.length !== 0 + ? formData.assignees_list + : prePopulatedData?.assignees && prePopulatedData?.assignees.toString() !== "none" + ? [prePopulatedData.assignees as any] + : [], + }); + + try { + quickAddStore.createIssue( + workspaceSlug.toString(), + projectId.toString(), + { + group_id: groupId ?? null, + sub_group_id: null, + }, + payload + ); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + } catch (err: any) { + Object.keys(err || {}).forEach((key) => { + const error = err?.[key]; + const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null; + + setToastAlert({ + type: "error", + title: "Error!", + message: errorTitle || "Some error occurred. Please try again.", + }); + }); + } + }; + + return ( +
+ +
+
+ + +
+
+ + {isOpen && ( +

+ Press {"'"}Enter{"'"} to add another issue +

+ )} + + {!isOpen && ( + + )} +
+ ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/roots/project-root.tsx b/web/components/issues/issue-layouts/spreadsheet/roots/project-root.tsx index 78940ceaf..a3856f5fb 100644 --- a/web/components/issues/issue-layouts/spreadsheet/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/roots/project-root.tsx @@ -66,6 +66,7 @@ export const ProjectSpreadsheetLayout: React.FC = observer(() => { handleIssueAction={() => {}} handleUpdateIssue={handleUpdateIssue} disableUserActions={false} + enableQuickCreateIssue /> ); }); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index 521773f6b..4d2a7cd04 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -3,11 +3,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { PlusIcon } from "lucide-react"; // components -import { - SpreadsheetColumnsList, - // ListInlineCreateIssueForm, - SpreadsheetIssuesColumn, -} from "components/issues"; +import { SpreadsheetColumnsList, SpreadsheetIssuesColumn, SpreadsheetInlineCreateIssueForm } from "components/issues"; import { CustomMenu, Spinner } from "@plane/ui"; // types import { @@ -31,6 +27,7 @@ type Props = { handleUpdateIssue: (issue: IIssue, data: Partial) => void; openIssuesListModal?: (() => void) | null; disableUserActions: boolean; + enableQuickCreateIssue?: boolean; }; export const SpreadsheetView: React.FC = observer((props) => { @@ -46,6 +43,7 @@ export const SpreadsheetView: React.FC = observer((props) => { handleUpdateIssue, openIssuesListModal, disableUserActions, + enableQuickCreateIssue, } = props; const [expandedIssues, setExpandedIssues] = useState([]); @@ -138,17 +136,10 @@ export const SpreadsheetView: React.FC = observer((props) => {
- {/* setIsInlineCreateIssueFormOpen(false)} - prePopulatedData={{ - ...(cycleId && { cycle: cycleId.toString() }), - ...(moduleId && { module: moduleId.toString() }), - }} - /> */} + {enableQuickCreateIssue && }
- {!disableUserActions && + {/* {!disableUserActions && !isInlineCreateIssueFormOpen && (type === "issue" ? (
diff --git a/web/components/issues/issue-peek-overview/activity/card.tsx b/web/components/issues/issue-peek-overview/activity/card.tsx index b66b5754f..8fbf8605a 100644 --- a/web/components/issues/issue-peek-overview/activity/card.tsx +++ b/web/components/issues/issue-peek-overview/activity/card.tsx @@ -12,6 +12,7 @@ import { render24HourFormatTime, renderLongDateFormat, timeAgo } from "helpers/d interface IssueActivityCard { workspaceSlug: string; projectId: string; + issueId: string; user: any; issueComments: any; issueCommentUpdate: (comment: any) => void; @@ -24,6 +25,7 @@ export const IssueActivityCard: FC = (props) => { const { workspaceSlug, projectId, + issueId, user, issueComments, issueCommentUpdate, @@ -118,6 +120,7 @@ export const IssueActivityCard: FC = (props) => { void; issueCommentReactionRemove: (commentId: string, reaction: string) => void; @@ -36,6 +37,7 @@ export const IssueCommentCard: React.FC = (props) => { showAccessSpecifier = false, workspaceSlug, projectId, + issueId, user, issueCommentReactionCreate, issueCommentReactionRemove, @@ -157,6 +159,7 @@ export const IssueCommentCard: React.FC = (props) => { = observer((props) => { - const { workspaceSlug, projectId, user, comment, issueCommentReactionCreate, issueCommentReactionRemove } = props; + const { workspaceSlug, projectId, issueId, user, comment, issueCommentReactionCreate, issueCommentReactionRemove } = + props; const { issueDetail: issueDetailStore }: RootStore = useMobxStore(); @@ -32,15 +34,18 @@ export const IssueCommentReaction: FC = observer((props) }; useSWR( - workspaceSlug && projectId && comment && comment?.id ? `ISSUE+PEEK_OVERVIEW_COMMENT_${comment?.id}` : null, + workspaceSlug && projectId && issueId && comment && comment?.id + ? `ISSUE+PEEK_OVERVIEW_COMMENT_${comment?.id}` + : null, () => { - if (workspaceSlug && projectId && comment && comment.id) { - issueDetailStore.fetchIssueCommentReactions(workspaceSlug, projectId, comment?.id); + if (workspaceSlug && projectId && issueId && comment && comment.id) { + issueDetailStore.fetchIssueCommentReactions(workspaceSlug, projectId, issueId, comment?.id); } } ); - const issueReactions = issueDetailStore?.getIssueCommentReactionsByCommentId(comment.id) || []; + let issueReactions = issueDetailStore?.getIssueCommentReactions || null; + issueReactions = issueReactions && comment.id ? issueReactions?.[comment.id] : []; return (
diff --git a/web/components/issues/issue-peek-overview/activity/view.tsx b/web/components/issues/issue-peek-overview/activity/view.tsx index f5db0f297..d7f9bcf92 100644 --- a/web/components/issues/issue-peek-overview/activity/view.tsx +++ b/web/components/issues/issue-peek-overview/activity/view.tsx @@ -6,6 +6,7 @@ import { IssueCommentEditor } from "./comment-editor"; interface IIssueComment { workspaceSlug: string; projectId: string; + issueId: string; user: any; issueComments: any; issueCommentCreate: (comment: any) => void; @@ -19,6 +20,7 @@ export const IssueComment: FC = (props) => { const { workspaceSlug, projectId, + issueId, user, issueComments, issueCommentCreate, @@ -46,6 +48,7 @@ export const IssueComment: FC = (props) => { ) => void; - - states: any; - members: any; + states: IState[] | null; + members: IUserLite[] | null; priorities: any; } export const PeekOverviewProperties: FC = (props) => { const { issue, issueUpdate, states, members, priorities } = props; - const handleState = (_state: string) => { - if (issueUpdate) issueUpdate({ ...issue, state: _state }); + const handleState = (_state: IState) => { + issueUpdate({ ...issue, state: _state.id }); }; - const handlePriority = (_priority: any) => { - if (issueUpdate) issueUpdate({ ...issue, priority: _priority }); + const handlePriority = (_priority: TIssuePriorities) => { + issueUpdate({ ...issue, priority: _priority }); }; const handleAssignee = (_assignees: string[]) => { - if (issueUpdate) issueUpdate({ ...issue, assignees: _assignees }); + issueUpdate({ ...issue, assignees: _assignees }); }; const handleStartDate = (_startDate: string) => { - if (issueUpdate) issueUpdate({ ...issue, start_date: _startDate }); + issueUpdate({ ...issue, start_date: _startDate }); }; const handleTargetDate = (_targetDate: string) => { - if (issueUpdate) issueUpdate({ ...issue, target_date: _targetDate }); + issueUpdate({ ...issue, target_date: _targetDate }); }; return ( @@ -54,11 +53,11 @@ export const PeekOverviewProperties: FC = (props) => {
handleState(id)} + value={issue?.state_detail || null} + onChange={handleState} + states={states} disabled={false} - list={states} + hideDropdownArrow={true} />
@@ -74,10 +73,10 @@ export const PeekOverviewProperties: FC = (props) => {
handleAssignee(ids)} disabled={false} - list={members} + hideDropdownArrow={true} + members={members} />
@@ -93,10 +92,9 @@ export const PeekOverviewProperties: FC = (props) => {
handlePriority(id)} + onChange={handlePriority} disabled={false} - list={priorities} + hideDropdownArrow={true} />
diff --git a/web/components/issues/issue-peek-overview/reactions/root.tsx b/web/components/issues/issue-peek-overview/reactions/root.tsx index 645ac6aab..efa2e488c 100644 --- a/web/components/issues/issue-peek-overview/reactions/root.tsx +++ b/web/components/issues/issue-peek-overview/reactions/root.tsx @@ -15,7 +15,7 @@ export const IssueReaction: FC = (props) => { const handleReaction = (reaction: string) => { const isReactionAvailable = - issueReactions[reaction].find((_reaction: any) => _reaction.actor === user?.id) ?? false; + issueReactions?.[reaction].find((_reaction: any) => _reaction.actor === user?.id) ?? false; if (isReactionAvailable) issueReactionRemove(reaction); else issueReactionCreate(reaction); diff --git a/web/components/issues/issue-peek-overview/root.tsx b/web/components/issues/issue-peek-overview/root.tsx index 8b96a3100..e94b728c0 100644 --- a/web/components/issues/issue-peek-overview/root.tsx +++ b/web/components/issues/issue-peek-overview/root.tsx @@ -50,10 +50,10 @@ export const IssuePeekOverview: FC = observer((props) => { issueDetailStore.removeIssueComment(workspaceSlug, projectId, issueId, commentId); const issueCommentReactionCreate = (commentId: string, reaction: string) => - issueDetailStore.creationIssueCommentReaction(workspaceSlug, projectId, commentId, reaction); + issueDetailStore.creationIssueCommentReaction(workspaceSlug, projectId, issueId, commentId, reaction); const issueCommentReactionRemove = (commentId: string, reaction: string) => - issueDetailStore.removeIssueCommentReaction(workspaceSlug, projectId, commentId, reaction); + issueDetailStore.removeIssueCommentReaction(workspaceSlug, projectId, issueId, commentId, reaction); return ( = observer((props) => { = observer((props) => { = observer((props) => { ); }; + const statesList = getStatesList(projectStore.states?.[issue.project]); + return (
{displayProperties.priority && ( @@ -132,7 +136,7 @@ export const IssueProperty: React.FC = observer((props) => {
handleStateChange(data)} hideDropdownArrow disabled={!editable} diff --git a/web/components/modules/index.ts b/web/components/modules/index.ts index 750db2fd0..c87ea79d2 100644 --- a/web/components/modules/index.ts +++ b/web/components/modules/index.ts @@ -7,3 +7,5 @@ export * from "./modal"; export * from "./modules-list-view"; export * from "./sidebar"; export * from "./module-card-item"; +export * from "./module-list-item"; +export * from "./module-peek-overview"; diff --git a/web/components/modules/module-card-item.tsx b/web/components/modules/module-card-item.tsx index 74dd20ad9..620333f8e 100644 --- a/web/components/modules/module-card-item.tsx +++ b/web/components/modules/module-card-item.tsx @@ -10,14 +10,16 @@ import useToast from "hooks/use-toast"; import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules"; // ui import { AssigneesList } from "components/ui"; -import { CustomMenu, Tooltip } from "@plane/ui"; +import { CustomMenu, LayersIcon, Tooltip } from "@plane/ui"; // icons -import { CalendarDays, LinkIcon, Pencil, Star, Target, Trash2 } from "lucide-react"; +import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; // helpers -import { copyUrlToClipboard, truncateText } from "helpers/string.helper"; -import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; +import { copyUrlToClipboard } from "helpers/string.helper"; +import { renderShortDate, renderShortMonthDate } from "helpers/date-time.helper"; // types import { IModule } from "types"; +// constants +import { MODULE_STATUS } from "constants/module"; type Props = { module: IModule; @@ -72,9 +74,32 @@ export const ModuleCardItem: React.FC = observer((props) => { }); }; + const openModuleOverview = () => { + const { query } = router; + + router.push({ + pathname: router.pathname, + query: { ...query, peekModule: module.id }, + }); + }; + const endDate = new Date(module.target_date ?? ""); const startDate = new Date(module.start_date ?? ""); - const lastUpdated = new Date(module.updated_at ?? ""); + + const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); + + const moduleStatus = MODULE_STATUS.find((status) => status.value === module.status); + + const issueCount = + module.completed_issues && module.total_issues + ? module.total_issues === 0 + ? "0 Issue" + : module.total_issues === module.completed_issues + ? module.total_issues > 1 + ? `${module.total_issues} Issues` + : `${module.total_issues} Issue` + : `${module.completed_issues}/${module.total_issues} Issues` + : "0 Issue"; return ( <> @@ -88,96 +113,142 @@ export const ModuleCardItem: React.FC = observer((props) => { /> )} setModuleDeleteModal(false)} /> -
-
-
-
- - - -

- {truncateText(module.name, 75)} -

-
- + + +
+
+ + {module.name} +
+ {moduleStatus && ( + + {moduleStatus.label} + + )} + +
+
+
-
-
- {module?.status?.replace("-", " ")} +
+
+
+ + {issueCount} +
+ {module.members_detail.length > 0 && ( + +
+ +
+
+ )} +
+ + +
+
+
+
+ + +
+ + {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} -{" "} + {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} + +
{module.is_favorite ? ( - ) : ( - )} - - - + + { + e.preventDefault(); + e.stopPropagation(); + setEditModuleModal(true); + }} + > - - Copy link - - - setEditModuleModal(true)}> - - + Edit module - setModuleDeleteModal(true)}> + { + e.preventDefault(); + e.stopPropagation(); + setModuleDeleteModal(true); + }} + > - + Delete module + { + e.preventDefault(); + e.stopPropagation(); + handleCopyText(); + }} + > + + + Copy module link + +
-
-
- - Start: - {renderShortDateWithYearFormat(startDate, "Not set")} -
-
- - End: - {renderShortDateWithYearFormat(endDate, "Not set")} -
-
-
-
-
- Progress -
-
-
- {isNaN(completionPercentage) ? 0 : completionPercentage.toFixed(0)}% -
-
-

- Last updated: - {renderShortDateWithYearFormat(lastUpdated)} -

- {module.members_detail.length > 0 && ( -
- -
- )} -
-
-
+
+ ); }); diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx new file mode 100644 index 000000000..8b1271cc8 --- /dev/null +++ b/web/components/modules/module-list-item.tsx @@ -0,0 +1,242 @@ +import React, { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import useToast from "hooks/use-toast"; +// components +import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules"; +// ui +import { AssigneesList } from "components/ui"; +import { CircularProgressIndicator, CustomMenu, Tooltip } from "@plane/ui"; +// icons +import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; +// helpers +import { copyUrlToClipboard } from "helpers/string.helper"; +import { renderShortDate, renderShortMonthDate } from "helpers/date-time.helper"; +// types +import { IModule } from "types"; +// constants +import { MODULE_STATUS } from "constants/module"; + +type Props = { + module: IModule; +}; + +export const ModuleListItem: React.FC = observer((props) => { + const { module } = props; + + const [editModuleModal, setEditModuleModal] = useState(false); + const [moduleDeleteModal, setModuleDeleteModal] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { setToastAlert } = useToast(); + + const { module: moduleStore } = useMobxStore(); + + const completionPercentage = ((module.completed_issues + module.cancelled_issues) / module.total_issues) * 100; + + const handleAddToFavorites = () => { + if (!workspaceSlug || !projectId) return; + + moduleStore.addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the module to favorites. Please try again.", + }); + }); + }; + + const handleRemoveFromFavorites = () => { + if (!workspaceSlug || !projectId) return; + + moduleStore.removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't remove the module from favorites. Please try again.", + }); + }); + }; + + const handleCopyText = () => { + copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${module.id}`).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Module link copied to clipboard.", + }); + }); + }; + + const endDate = new Date(module.target_date ?? ""); + const startDate = new Date(module.start_date ?? ""); + + const renderDate = module.start_date || module.target_date; + + const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); + + const moduleStatus = MODULE_STATUS.find((status) => status.value === module.status); + + const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); + + const completedModuleCheck = module.status === "completed" && module.total_issues - module.completed_issues; + + const openModuleOverview = () => { + const { query } = router; + + router.push({ + pathname: router.pathname, + query: { ...query, peekModule: module.id }, + }); + }; + + return ( + <> + {workspaceSlug && projectId && ( + setEditModuleModal(false)} + data={module} + projectId={projectId.toString()} + workspaceSlug={workspaceSlug.toString()} + /> + )} + setModuleDeleteModal(false)} /> + + +
+
+ + + {completedModuleCheck ? ( + {`!`} + ) : progress === 100 ? ( + + ) : ( + {`${progress}%`} + )} + + + + {module.name} + +
+ +
+ +
+
+ {moduleStatus && ( + + {moduleStatus.label} + + )} +
+ + {renderDate && ( + + {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} + {" - "} + {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} + + )} + + +
+ {module.members_detail.length > 0 ? ( + + ) : ( + + + + )} +
+
+ + {module.is_favorite ? ( + + ) : ( + + )} + + + { + e.preventDefault(); + e.stopPropagation(); + setEditModuleModal(true); + }} + > + + + Edit module + + + { + e.preventDefault(); + e.stopPropagation(); + setModuleDeleteModal(true); + }} + > + + + Delete module + + + { + e.preventDefault(); + e.stopPropagation(); + handleCopyText(); + }} + > + + + Copy module link + + + +
+
+ + + ); +}); diff --git a/web/components/modules/module-peek-overview.tsx b/web/components/modules/module-peek-overview.tsx new file mode 100644 index 000000000..c7d2630cb --- /dev/null +++ b/web/components/modules/module-peek-overview.tsx @@ -0,0 +1,55 @@ +import React, { useEffect } from "react"; + +import { useRouter } from "next/router"; + +// mobx +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; +// components +import { ModuleDetailsSidebar } from "./sidebar"; + +type Props = { + projectId: string; + workspaceSlug: string; +}; + +export const ModulePeekOverview: React.FC = observer(({ projectId, workspaceSlug }) => { + const router = useRouter(); + const { peekModule } = router.query; + + const ref = React.useRef(null); + + const { module: moduleStore } = useMobxStore(); + const { fetchModuleDetails } = moduleStore; + + const handleClose = () => { + delete router.query.peekModule; + router.push({ + pathname: router.pathname, + query: { ...router.query }, + }); + }; + + useEffect(() => { + if (!peekModule) return; + + fetchModuleDetails(workspaceSlug, projectId, peekModule.toString()); + }, [fetchModuleDetails, peekModule, projectId, workspaceSlug]); + + return ( + <> + {peekModule && ( +
+ +
+ )} + + ); +}); diff --git a/web/components/modules/modules-list-view.tsx b/web/components/modules/modules-list-view.tsx index d40b72a31..457f43524 100644 --- a/web/components/modules/modules-list-view.tsx +++ b/web/components/modules/modules-list-view.tsx @@ -1,3 +1,4 @@ +import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Plus } from "lucide-react"; // mobx store @@ -5,7 +6,7 @@ import { useMobxStore } from "lib/mobx/store-provider"; // hooks import useLocalStorage from "hooks/use-local-storage"; // components -import { ModuleCardItem, ModulesListGanttChartView } from "components/modules"; +import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "components/modules"; import { EmptyState } from "components/common"; // ui import { Loader } from "@plane/ui"; @@ -13,6 +14,9 @@ import { Loader } from "@plane/ui"; import emptyModule from "public/empty-state/module.svg"; export const ModulesListView: React.FC = observer(() => { + const router = useRouter(); + const { workspaceSlug, projectId, peekModule } = router.query; + const { module: moduleStore } = useMobxStore(); const { storedValue: modulesView } = useLocalStorage("modules_view", "grid"); @@ -22,12 +26,12 @@ export const ModulesListView: React.FC = observer(() => { if (!modulesList) return ( - - - - - - + + + + + + ); @@ -35,12 +39,39 @@ export const ModulesListView: React.FC = observer(() => { <> {modulesList.length > 0 ? ( <> + {modulesView === "list" && ( +
+
+
+ {modulesList.map((module) => ( + + ))} +
+ +
+
+ )} {modulesView === "grid" && ( -
-
- {modulesList.map((module) => ( - - ))} +
+
+
+ {modulesList.map((module) => ( + + ))} +
+
)} diff --git a/web/components/modules/sidebar-select/select-lead.tsx b/web/components/modules/sidebar-select/select-lead.tsx index 83ee93404..020aad037 100644 --- a/web/components/modules/sidebar-select/select-lead.tsx +++ b/web/components/modules/sidebar-select/select-lead.tsx @@ -7,7 +7,7 @@ import { ProjectService } from "services/project"; import { Avatar } from "components/ui"; import { CustomSearchSelect } from "@plane/ui"; // icons -import { UserCircle2 } from "lucide-react"; +import { ChevronDown, UserCircle2 } from "lucide-react"; // fetch-keys import { PROJECT_MEMBERS } from "constants/fetch-keys"; @@ -36,7 +36,7 @@ export const SidebarLeadSelect: FC = (props) => { query: member.member.display_name, content: (
- + {member.member.display_name}
), @@ -46,18 +46,27 @@ export const SidebarLeadSelect: FC = (props) => { return (
-
- - Lead +
+ + Lead
-
+
- {selectedOption && } - {selectedOption ? selectedOption?.display_name : No lead} -
+ customButtonClassName="rounded-sm" + customButton={ + selectedOption ? ( +
+ + {selectedOption?.display_name} +
+ ) : ( +
+ No lead + +
+ ) } options={options} maxHeight="md" diff --git a/web/components/modules/sidebar-select/select-members.tsx b/web/components/modules/sidebar-select/select-members.tsx index c25959e3f..7b74ef794 100644 --- a/web/components/modules/sidebar-select/select-members.tsx +++ b/web/components/modules/sidebar-select/select-members.tsx @@ -10,6 +10,7 @@ import { ProjectService } from "services/project"; import { AssigneesList, Avatar } from "components/ui"; import { CustomSearchSelect, UserGroupIcon } from "@plane/ui"; // icons +import { ChevronDown } from "lucide-react"; // fetch-keys import { PROJECT_MEMBERS } from "constants/fetch-keys"; @@ -37,7 +38,7 @@ export const SidebarMembersSelect: React.FC = ({ value, onChange }) => { query: member.member.display_name, content: (
- + {member.member.display_name}
), @@ -45,24 +46,26 @@ export const SidebarMembersSelect: React.FC = ({ value, onChange }) => { return (
-
- - Members +
+ + Members
-
+
- {value && value.length > 0 && Array.isArray(value) ? ( -
- - {value.length} Assignees -
- ) : ( - "No members" - )} -
+ customButtonClassName="rounded-sm" + customButton={ + value && value.length > 0 && Array.isArray(value) ? ( +
+ +
+ ) : ( +
+ No members + +
+ ) } options={options} onChange={onChange} diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index c6a2818ee..aa5462e08 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -3,8 +3,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { mutate } from "swr"; import { Controller, useForm } from "react-hook-form"; -import { Disclosure, Popover, Transition } from "@headlessui/react"; -import DatePicker from "react-datepicker"; +import { Disclosure, Transition } from "@headlessui/react"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // services @@ -18,22 +17,12 @@ import { LinkModal, LinksList, SidebarProgressStats } from "components/core"; import { DeleteModuleModal, SidebarLeadSelect, SidebarMembersSelect } from "components/modules"; import ProgressChart from "components/core/sidebar/progress-chart"; // ui -import { CustomSelect, CustomMenu, Loader, ProgressBar } from "@plane/ui"; +import { CustomMenu, Loader, LayersIcon } from "@plane/ui"; // icon -import { - AlertCircle, - CalendarDays, - ChevronDown, - File, - LinkIcon, - MoveRight, - PieChart, - Plus, - Trash2, -} from "lucide-react"; +import { AlertCircle, ChevronDown, ChevronRight, Info, LinkIcon, Plus, Trash2 } from "lucide-react"; // helpers -import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper"; -import { capitalizeFirstLetter, copyUrlToClipboard } from "helpers/string.helper"; +import { renderShortDate, renderShortMonthDate } from "helpers/date-time.helper"; +import { copyUrlToClipboard } from "helpers/string.helper"; // types import { linkDetails, IModule, ModuleLink } from "types"; // fetch-keys @@ -50,8 +39,8 @@ const defaultValues: Partial = { }; type Props = { - isOpen: boolean; moduleId: string; + handleClose: () => void; }; // services @@ -59,14 +48,14 @@ const moduleService = new ModuleService(); // TODO: refactor this component export const ModuleDetailsSidebar: React.FC = observer((props) => { - const { isOpen, moduleId } = props; + const { moduleId, handleClose } = props; const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [moduleLinkModal, setModuleLinkModal] = useState(false); const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState(null); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId, peekModule } = router.query; const { module: moduleStore, user: userStore } = useMobxStore(); @@ -77,7 +66,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const { setToastAlert } = useToast(); - const { reset, watch, control } = useForm({ + const { reset, control } = useForm({ defaultValues, }); @@ -209,12 +198,29 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { : null; const handleEditLink = (link: linkDetails) => { + console.log("link", link); setSelectedLinkToUpdate(link); setModuleLinkModal(true); }; if (!moduleDetails) return null; + const startDate = new Date(moduleDetails.start_date ?? ""); + const endDate = new Date(moduleDetails.target_date ?? ""); + + const areYearsEqual = startDate.getFullYear() === endDate.getFullYear(); + + const moduleStatus = MODULE_STATUS.find((status) => status.value === moduleDetails.status); + + const issueCount = + moduleDetails.total_issues === 0 + ? "0 Issue" + : moduleDetails.total_issues === moduleDetails.completed_issues + ? moduleDetails.total_issues > 1 + ? `${moduleDetails.total_issues}` + : `${moduleDetails.total_issues}` + : `${moduleDetails.completed_issues}/${moduleDetails.total_issues}`; + return ( <> = observer((props) => { updateIssueLink={handleUpdateLink} /> setModuleDeleteModal(false)} data={moduleDetails} /> -
- {module ? ( - <> -
-
-
- ( - - {capitalizeFirstLetter(`${watch("status")}`)} - - } - value={value} - onChange={(value: any) => { - submitChanges({ status: value }); - }} - > - {MODULE_STATUS.map((option) => ( - - {option.label} - - ))} - - )} - /> -
-
- - {({}) => ( - <> - - - - {renderShortDateWithYearFormat(new Date(`${moduleDetails.start_date}`), "Start date")} - - - - - - { - submitChanges({ - start_date: renderDateFormat(date), - }); - }} - selectsStart - startDate={new Date(`${watch("start_date")}`)} - endDate={new Date(`${watch("target_date")}`)} - maxDate={new Date(`${watch("target_date")}`)} - shouldCloseOnSelect - inline - /> - - - - )} - - - + {module ? ( + <> +
+
+ {peekModule && ( + + )} +
+
+ + + setModuleDeleteModal(true)}> + + + Delete - - {({}) => ( - <> - - + + +
+
- - {renderShortDateWithYearFormat(new Date(`${moduleDetails?.target_date}`), "End date")} - - +
+

{moduleDetails.name}

+
+ {moduleStatus && ( + + {moduleStatus.label} + + )} + + {areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} -{" "} + {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} + +
+
- - - { - submitChanges({ - target_date: renderDateFormat(date), - }); - }} - selectsEnd - startDate={new Date(`${watch("start_date")}`)} - endDate={new Date(`${watch("target_date")}`)} - minDate={new Date(`${watch("start_date")}`)} - shouldCloseOnSelect - inline - /> - - - - )} - -
+ {moduleDetails.description && ( + + {moduleDetails.description} + + )} + +
+ ( + { + submitChanges({ lead: val }); + }} + /> + )} + /> + ( + { + submitChanges({ members_list: val }); + }} + /> + )} + /> + +
+
+ + Issues
- -
-
-
-
-

- {moduleDetails.name} -

-
- - setModuleDeleteModal(true)}> - - - Delete - - - - - - Copy link - - - -
- - - {moduleDetails.description} - -
- -
- ( - { - submitChanges({ lead: val }); - }} - /> - )} - /> - ( - { - submitChanges({ members_list: val }); - }} - /> - )} - /> - -
-
- - Progress -
- -
- - - - {moduleDetails.completed_issues}/{moduleDetails.total_issues} -
-
-
+
+ {issueCount}
+
-
- +
+
+ {({ open }) => (
-
+
Progress - {!open && progressPercentage ? ( - +
+ +
+ {progressPercentage ? ( + {progressPercentage ? `${progressPercentage}%` : ""} ) : ( "" )} -
- - {isStartValid && isEndValid ? ( - - - ) : ( -
- - - Invalid date. Please enter valid date. - -
- )} -
- - {isStartValid && isEndValid ? ( -
-
-
- - - - - Pending Issues -{" "} - {moduleDetails.total_issues - - (moduleDetails.completed_issues + moduleDetails.cancelled_issues)}{" "} - -
- -
-
- - Ideal -
-
- - Current -
-
-
-
- -
-
+ + ) : ( - "" +
+ + + Invalid date. Please enter valid date. + +
)} -
-
-
- )} - -
- -
- - {({ open }) => ( -
-
-
- Other Information
- - {moduleDetails.total_issues > 0 ? ( - - - ) : ( -
- - - No issues found. Please add issue. - -
- )}
- {moduleDetails.total_issues > 0 ? ( - <> -
+
+ {isStartValid && isEndValid ? ( +
+
+
+
+ + Ideal +
+
+ + Current +
+
+
+
+ +
+
+ ) : ( + "" + )} + {moduleDetails.total_issues > 0 && ( +
= observer((props) => { }} totalIssues={moduleDetails.total_issues} module={moduleDetails} + isPeekModuleDetails={Boolean(peekModule)} />
- - ) : ( - "" - )} + )} +
@@ -555,42 +412,83 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => {
-
-
-

Links

- -
-
- {memberRole && moduleDetails.link_module && moduleDetails.link_module.length > 0 ? ( - - ) : null} -
+
+ + {({ open }) => ( +
+
+
+ Links +
+ +
+ + +
+
+ + +
+ {memberRole && moduleDetails.link_module && moduleDetails.link_module.length > 0 ? ( + <> +
+ +
+ + + + ) : ( +
+
+ + No links added yet +
+ +
+ )} +
+
+
+
+ )} +
- - ) : ( - -
- - -
-
- - - -
-
- )} -
+
+ + ) : ( + +
+ + +
+
+ + + +
+
+ )} ); }); diff --git a/web/components/profile/navbar.tsx b/web/components/profile/navbar.tsx index cd43cc974..433452536 100644 --- a/web/components/profile/navbar.tsx +++ b/web/components/profile/navbar.tsx @@ -5,11 +5,9 @@ import Link from "next/link"; // components import { ProfileIssuesFilter } from "components/profile"; -// types -import { UserAuth } from "types"; type Props = { - memberRole: UserAuth; + isAuthorized: boolean; }; const viewerTabs = [ @@ -38,12 +36,11 @@ const adminTabs = [ }, ]; -export const ProfileNavbar: React.FC = ({ memberRole }) => { +export const ProfileNavbar: React.FC = ({ isAuthorized }) => { const router = useRouter(); const { workspaceSlug, userId } = router.query; - const tabsList = - memberRole.isOwner || memberRole.isMember || memberRole.isViewer ? [...viewerTabs, ...adminTabs] : viewerTabs; + const tabsList = isAuthorized ? [...viewerTabs, ...adminTabs] : viewerTabs; return (
diff --git a/web/components/project/delete-project-modal.tsx b/web/components/project/delete-project-modal.tsx index 010341688..a40e37403 100644 --- a/web/components/project/delete-project-modal.tsx +++ b/web/components/project/delete-project-modal.tsx @@ -30,7 +30,7 @@ export const DeleteProjectModal: React.FC = (props) => { const { project: projectStore } = useMobxStore(); // router const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; // toast const { setToastAlert } = useToast(); // form info @@ -59,6 +59,8 @@ export const DeleteProjectModal: React.FC = (props) => { await projectStore .deleteProject(workspaceSlug.toString(), project.id) .then(() => { + if (projectId && projectId.toString() === project.id) router.push(`/${workspaceSlug}/projects`); + handleClose(); }) .catch(() => { diff --git a/web/components/project/index.ts b/web/components/project/index.ts index ff0213d52..040a0f3df 100644 --- a/web/components/project/index.ts +++ b/web/components/project/index.ts @@ -1,18 +1,18 @@ +export * from "./publish-project"; +export * from "./settings"; +export * from "./card-list"; +export * from "./card"; export * from "./create-project-modal"; export * from "./delete-project-modal"; -export * from "./sidebar-list"; -export * from "./settings-sidebar"; -export * from "./single-integration-card"; -export * from "./sidebar-list-item"; +export * from "./delete-project-section"; +export * from "./form-loader"; +export * from "./form"; +export * from "./join-project-modal"; +export * from "./label-select"; export * from "./leave-project-modal"; export * from "./member-select"; export * from "./members-select"; -export * from "./label-select"; export * from "./priority-select"; -export * from "./card-list"; -export * from "./card"; -export * from "./join-project-modal"; -export * from "./form"; -export * from "./form-loader"; -export * from "./delete-project-section"; -export * from "./publish-project"; +export * from "./sidebar-list-item"; +export * from "./sidebar-list"; +export * from "./single-integration-card"; diff --git a/web/components/project/label-select.tsx b/web/components/project/label-select.tsx index f715793cb..ebf7f4776 100644 --- a/web/components/project/label-select.tsx +++ b/web/components/project/label-select.tsx @@ -3,9 +3,6 @@ import { usePopper } from "react-popper"; import { Placement } from "@popperjs/core"; import { Combobox } from "@headlessui/react"; import { Check, ChevronDown, PlusIcon, Search } from "lucide-react"; - -// components -import { CreateLabelModal } from "components/labels"; // ui import { Tooltip } from "components/ui"; // types @@ -14,7 +11,7 @@ import { IIssueLabels } from "types"; type Props = { value: string[]; onChange: (data: string[]) => void; - labels: IIssueLabels[]; + labels: IIssueLabels[] | undefined; className?: string; buttonClassName?: string; optionsClassName?: string; @@ -41,10 +38,16 @@ export const LabelSelect: React.FC = ({ const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); - const [labelModal, setLabelModal] = useState(false); - const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], }); const options = labels?.map((label) => ({ @@ -66,149 +69,126 @@ export const LabelSelect: React.FC = ({ const filteredOptions = query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase())); - const footerOption = ( - - ); - return ( - <> - {/* TODO: update this logic */} - {/* {projectId && ( - setLabelModal(false)} - projectId={projectId} - user={user} - /> - )} */} - - - - - - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
-
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${ - active && !selected ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( - -

No matching results

-
- ) - ) : ( -

Loading...

- )} -
- {footerOption} +
+ value.includes(l.id)) + .map((l) => l.name) + .join(", ")} + > +
+ + {`${value.length} Labels`} +
+
+
+ ) + ) : ( +
+ Select labels +
+ )}
-
-
- + {!hideDropdownArrow && !disabled &&