diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 4cdcc7b76..be98bc312 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -562,7 +562,7 @@ class IssueSerializer(DynamicBaseSerializer): state_id = serializers.PrimaryKeyRelatedField(read_only=True) parent_id = serializers.PrimaryKeyRelatedField(read_only=True) cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) - module_id = serializers.PrimaryKeyRelatedField(read_only=True) + module_ids = serializers.SerializerMethodField() # Many to many label_ids = serializers.PrimaryKeyRelatedField( @@ -597,7 +597,7 @@ class IssueSerializer(DynamicBaseSerializer): "project_id", "parent_id", "cycle_id", - "module_id", + "module_ids", "label_ids", "assignee_ids", "sub_issues_count", @@ -613,6 +613,10 @@ class IssueSerializer(DynamicBaseSerializer): ] read_only_fields = fields + def get_module_ids(self, obj): + # Access the prefetched modules and extract module IDs + return [module for module in obj.issue_module.values_list("module_id", flat=True)] + class IssueLiteSerializer(DynamicBaseSerializer): workspace_detail = WorkspaceLiteSerializer( diff --git a/apiserver/plane/app/urls/module.py b/apiserver/plane/app/urls/module.py index d81d32d3a..5e9f4f123 100644 --- a/apiserver/plane/app/urls/module.py +++ b/apiserver/plane/app/urls/module.py @@ -35,17 +35,26 @@ urlpatterns = [ name="project-modules", ), path( - "workspaces//projects//modules//module-issues/", + "workspaces//projects//issues//modules/", ModuleIssueViewSet.as_view( { + "post": "create_issue_modules", + } + ), + name="issue-module", + ), + path( + "workspaces//projects//modules//issues/", + ModuleIssueViewSet.as_view( + { + "post": "create_module_issues", "get": "list", - "post": "create", } ), name="project-module-issues", ), path( - "workspaces//projects//modules//module-issues//", + "workspaces//projects//modules//issues//", ModuleIssueViewSet.as_view( { "get": "retrieve", diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 3c54f7f95..23a227fef 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -599,16 +599,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): ) .filter(project_id=project_id) .filter(workspace__slug=slug) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") .order_by(order_by) .filter(**filters) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() diff --git a/apiserver/plane/app/views/dashboard.py b/apiserver/plane/app/views/dashboard.py index af476a130..47fae2c9c 100644 --- a/apiserver/plane/app/views/dashboard.py +++ b/apiserver/plane/app/views/dashboard.py @@ -100,7 +100,7 @@ def dashboard_assigned_issues(self, request, slug): ) .filter(**filters) .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels") + .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( Prefetch( "issue_relation", @@ -110,7 +110,6 @@ def dashboard_assigned_issues(self, request, slug): ) ) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -221,9 +220,8 @@ def dashboard_created_issues(self, request, slug): ) .filter(**filters) .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels") + .prefetch_related("assignees", "labels", "issue_module__module") .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() diff --git a/apiserver/plane/app/views/inbox.py b/apiserver/plane/app/views/inbox.py index 3bacdae4c..01eee78e3 100644 --- a/apiserver/plane/app/views/inbox.py +++ b/apiserver/plane/app/views/inbox.py @@ -95,7 +95,7 @@ class InboxIssueViewSet(BaseViewSet): issue_inbox__inbox_id=self.kwargs.get("inbox_id") ) .select_related("workspace", "project", "state", "parent") - .prefetch_related("labels", "assignees") + .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( Prefetch( "issue_inbox", @@ -105,7 +105,6 @@ class InboxIssueViewSet(BaseViewSet): ) ) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index 5ea02e40e..0b5c612d3 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -112,12 +112,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet): project_id=self.kwargs.get("project_id") ) .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( Prefetch( "issue_reactions", @@ -125,7 +121,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet): ) ) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -1087,12 +1082,31 @@ class IssueArchiveViewSet(BaseViewSet): .filter(archived_at__isnull=False) .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) ) @method_decorator(gzip_page) @@ -1120,22 +1134,6 @@ class IssueArchiveViewSet(BaseViewSet): issue_queryset = ( self.get_queryset() .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) ) # Priority Ordering @@ -1681,18 +1679,37 @@ class IssueDraftViewSet(BaseViewSet): .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) .filter(is_draft=True) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( Prefetch( "issue_reactions", queryset=IssueReaction.objects.select_related("actor"), ) ) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) ) @method_decorator(gzip_page) @@ -1719,22 +1736,6 @@ class IssueDraftViewSet(BaseViewSet): issue_queryset = ( self.get_queryset() .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) ) # Priority Ordering diff --git a/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py index 969adc2a5..1f055129a 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -7,6 +7,8 @@ from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q from django.core import serializers from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page +from django.core.serializers.json import DjangoJSONEncoder + # Third party imports from rest_framework.response import Response @@ -296,23 +298,20 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "issue", flat=True ) ) - issue_activity.delay( - type="module.activity.deleted", - requested_data=json.dumps( - { - "module_id": str(pk), - "module_name": str(module.name), - "issues": [str(issue_id) for issue_id in module_issues], - } - ), - actor_id=str(request.user.id), - issue_id=str(pk), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) + _ = [ + issue_activity.delay( + type="module.activity.deleted", + requested_data=json.dumps({"module_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue), + project_id=project_id, + current_instance=json.dumps({"module_name": str(module.name)}), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + for issue in module_issues + ] module.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -332,62 +331,18 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ProjectEntityPermission, ] - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("issue") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(module_id=self.kwargs.get("module_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("module") - .select_related("issue", "issue__state", "issue__project") - .prefetch_related("issue__assignees", "issue__labels") - .prefetch_related("module__members") - .distinct() - ) - @method_decorator(gzip_page) - def list(self, request, slug, project_id, module_id): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] - order_by = request.GET.get("order_by", "created_at") - filters = issue_filters(request.query_params, "GET") - issues = ( - Issue.issue_objects.filter(issue_module__module_id=module_id) - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") + def get_queryset(self): + return ( + Issue.objects.filter( + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + issue_module__module_id=self.kwargs.get("module_id") ) - .filter(project_id=project_id) - .filter(workspace__slug=slug) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .order_by(order_by) - .filter(**filters) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("labels", "assignees") + .prefetch_related('issue_module__module') .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -403,105 +358,118 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): .values("count") ) .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - subscriber=self.request.user, issue_id=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") ) - ) + ).distinct() + + @method_decorator(gzip_page) + def list(self, request, slug, project_id, module_id): + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] + filters = issue_filters(request.query_params, "GET") + issue_queryset = self.get_queryset().filter(**filters) serializer = IssueSerializer( - issues, many=True, fields=fields if fields else None + issue_queryset, many=True, fields=fields if fields else None ) return Response(serializer.data, status=status.HTTP_200_OK) - def create(self, request, slug, project_id, module_id): + # create multiple issues inside a module + def create_module_issues(self, request, slug, project_id, module_id): issues = request.data.get("issues", []) if not len(issues): return Response( {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST, ) - module = Module.objects.get( - workspace__slug=slug, project_id=project_id, pk=module_id - ) - - module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues)) - - update_module_issue_activity = [] - records_to_update = [] - record_to_create = [] - - for issue in issues: - module_issue = [ - module_issue - for module_issue in module_issues - if str(module_issue.issue_id) in issues - ] - - if len(module_issue): - if module_issue[0].module_id != module_id: - update_module_issue_activity.append( - { - "old_module_id": str(module_issue[0].module_id), - "new_module_id": str(module_id), - "issue_id": str(module_issue[0].issue_id), - } - ) - module_issue[0].module_id = module_id - records_to_update.append(module_issue[0]) - else: - record_to_create.append( - ModuleIssue( - module=module, - issue_id=issue, - project_id=project_id, - workspace=module.workspace, - created_by=request.user, - updated_by=request.user, - ) + project = Project.objects.get(pk=project_id) + _ = ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + issue_id=str(issue), + module_id=module_id, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, ) - - ModuleIssue.objects.bulk_create( - record_to_create, + for issue in issues + ], batch_size=10, ignore_conflicts=True, ) + # Bulk Update the activity + _ = [ + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"module_id": str(module_id)}), + actor_id=str(request.user.id), + issue_id=str(issue), + project_id=project_id, + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + for issue in issues + ] + issues = (self.get_queryset().filter(pk__in=issues)) + serializer = IssueSerializer(issues , many=True) + return Response(serializer.data, status=status.HTTP_201_CREATED) + - ModuleIssue.objects.bulk_update( - records_to_update, - ["module"], + # create multiple module inside an issue + def create_issue_modules(self, request, slug, project_id, issue_id): + modules = request.data.get("modules", []) + if not len(modules): + return Response( + {"error": "Modules are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + project = Project.objects.get(pk=project_id) + _ = ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + issue_id=issue_id, + module_id=module, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for module in modules + ], batch_size=10, + ignore_conflicts=True, ) + # Bulk Update the activity + _ = [ + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"module_id": module}), + actor_id=str(request.user.id), + issue_id=issue_id, + project_id=project_id, + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + for module in modules + ] - # Capture Issue Activity - issue_activity.delay( - type="module.activity.created", - requested_data=json.dumps({"modules_list": issues}), - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "updated_module_issues": update_module_issue_activity, - "created_module_issues": serializers.serialize( - "json", record_to_create - ), - } - ), - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) + issue = (self.get_queryset().filter(pk=issue_id).first()) + serializer = IssueSerializer(issue) + return Response(serializer.data, status=status.HTTP_201_CREATED) - issues = self.get_queryset().values_list("issue_id", flat=True) - - return Response( - IssueSerializer( - Issue.objects.filter(pk__in=issues), many=True - ).data, - status=status.HTTP_200_OK, - ) def destroy(self, request, slug, project_id, module_id, issue_id): module_issue = ModuleIssue.objects.get( @@ -512,16 +480,11 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ) issue_activity.delay( type="module.activity.deleted", - requested_data=json.dumps( - { - "module_id": str(module_id), - "issues": [str(issue_id)], - } - ), + requested_data=json.dumps({"module_id": str(module_id)}), actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), - current_instance=None, + current_instance=json.dumps({"module_name": module_issue.module.name}), epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search.py index 0455541c6..13acabfe8 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search.py @@ -228,7 +228,7 @@ class IssueSearchEndpoint(BaseAPIView): parent = request.query_params.get("parent", "false") issue_relation = request.query_params.get("issue_relation", "false") cycle = request.query_params.get("cycle", "false") - module = request.query_params.get("module", "false") + module = request.query_params.get("module", False) sub_issue = request.query_params.get("sub_issue", "false") issue_id = request.query_params.get("issue_id", False) @@ -269,8 +269,8 @@ class IssueSearchEndpoint(BaseAPIView): if cycle == "true": issues = issues.exclude(issue_cycle__isnull=False) - if module == "true": - issues = issues.exclude(issue_module__isnull=False) + if module: + issues = issues.exclude(issue_module__module=module) return Response( issues.values( diff --git a/apiserver/plane/app/views/view.py b/apiserver/plane/app/views/view.py index 07bf1ad03..27f31f7a9 100644 --- a/apiserver/plane/app/views/view.py +++ b/apiserver/plane/app/views/view.py @@ -87,12 +87,8 @@ class GlobalViewIssuesViewSet(BaseViewSet): .values("count") ) .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( Prefetch( "issue_reactions", @@ -127,7 +123,6 @@ class GlobalViewIssuesViewSet(BaseViewSet): .filter(**filters) .filter(project__project_projectmember__member=self.request.user) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -150,13 +145,6 @@ class GlobalViewIssuesViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - subscriber=self.request.user, issue_id=OuterRef("id") - ) - ) - ) ) # Priority Ordering diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index 159fbcb08..f4d3dbbb5 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -1346,9 +1346,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): ) .filter(**filters) .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels") + .prefetch_related("assignees", "labels", "issue_module__module") .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index cf7255585..cc9588ca6 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -148,10 +148,12 @@ def send_email_notification( template_data = [] total_changes = 0 comments = [] + actors_involved = [] for actor_id, changes in data.items(): actor = User.objects.get(pk=actor_id) total_changes = total_changes + len(changes) comment = changes.pop("comment", False) + actors_involved.append(actor_id) if comment: comments.append( { @@ -191,6 +193,7 @@ def send_email_notification( context = { "data": template_data, "summary": summary, + "actors_involved": len(set(actors_involved)), "issue": { "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", "name": issue.name, @@ -200,6 +203,9 @@ def send_email_notification( "email": receiver.email, }, "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", + "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", + "workspace":str(issue.project.workspace.slug), + "project": str(issue.project.name), "user_preference": f"{base_api}/profile/preferences/email", "comments": comments, } diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 4a036ec31..b9f6bd411 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -30,6 +30,7 @@ from plane.app.serializers import IssueActivitySerializer from plane.bgtasks.notification_task import notifications from plane.settings.redis import redis_instance + # Track Changes in name def track_name( requested_data, @@ -852,70 +853,26 @@ def create_module_issue_activity( requested_data = ( json.loads(requested_data) if requested_data is not None else None ) - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) - - # Updated Records: - updated_records = current_instance.get("updated_module_issues", []) - created_records = json.loads( - current_instance.get("created_module_issues", []) - ) - - for updated_record in updated_records: - old_module = Module.objects.filter( - pk=updated_record.get("old_module_id", None) - ).first() - new_module = Module.objects.filter( - pk=updated_record.get("new_module_id", None) - ).first() - issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first() - if issue: - issue.updated_at = timezone.now() - issue.save(update_fields=["updated_at"]) - - issue_activities.append( - IssueActivity( - issue_id=updated_record.get("issue_id"), - actor_id=actor_id, - verb="updated", - old_value=old_module.name, - new_value=new_module.name, - field="modules", - 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, - ) - ) - - for created_record in created_records: - module = Module.objects.filter( - pk=created_record.get("fields").get("module") - ).first() - issue = Issue.objects.filter( - pk=created_record.get("fields").get("issue") - ).first() - if issue: - issue.updated_at = timezone.now() - issue.save(update_fields=["updated_at"]) - issue_activities.append( - IssueActivity( - issue_id=created_record.get("fields").get("issue"), - actor_id=actor_id, - verb="created", - old_value="", - new_value=module.name, - field="modules", - project_id=project_id, - workspace_id=workspace_id, - comment=f"added module {module.name}", - new_identifier=module.id, - epoch=epoch, - ) + module = Module.objects.filter(pk=requested_data.get("module_id")).first() + issue = Issue.objects.filter(pk=issue_id).first() + if issue: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="created", + old_value="", + new_value=module.name, + field="modules", + project_id=project_id, + workspace_id=workspace_id, + comment=f"added module {module.name}", + new_identifier=requested_data.get("module_id"), + epoch=epoch, ) + ) def delete_module_issue_activity( @@ -934,32 +891,26 @@ def delete_module_issue_activity( current_instance = ( json.loads(current_instance) if current_instance is not None else None ) - - module_id = requested_data.get("module_id", "") - module_name = requested_data.get("module_name", "") - module = Module.objects.filter(pk=module_id).first() - issues = requested_data.get("issues") - - for issue in issues: - current_issue = Issue.objects.filter(pk=issue).first() - if issue: - current_issue.updated_at = timezone.now() - current_issue.save(update_fields=["updated_at"]) - issue_activities.append( - IssueActivity( - issue_id=issue, - actor_id=actor_id, - verb="deleted", - old_value=module.name if module is not None else module_name, - new_value="", - field="modules", - project_id=project_id, - workspace_id=workspace_id, - comment=f"removed this issue from {module.name if module is not None else module_name}", - old_identifier=module_id if module_id is not None else None, - epoch=epoch, - ) + module_name = current_instance.get("module_name") + current_issue = Issue.objects.filter(pk=issue_id).first() + if current_issue: + current_issue.updated_at = timezone.now() + current_issue.save(update_fields=["updated_at"]) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="deleted", + old_value=module_name, + new_value="", + field="modules", + project_id=project_id, + workspace_id=workspace_id, + comment=f"removed this issue from {module_name}", + old_identifier=requested_data.get("module_id") if requested_data.get("module_id") is not None else None, + epoch=epoch, ) + ) def create_link_activity( @@ -1648,7 +1599,6 @@ def issue_activity( ) except Exception as e: capture_exception(e) - if notification: notifications.delay( diff --git a/apiserver/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py b/apiserver/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py new file mode 100644 index 000000000..6238ef825 --- /dev/null +++ b/apiserver/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 on 2024-01-24 18:55 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0057_auto_20240122_0901'), + ] + + operations = [ + migrations.AlterField( + model_name='moduleissue', + name='issue', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.issue'), + ), + migrations.AlterUniqueTogether( + name='moduleissue', + unique_together={('issue', 'module')}, + ), + ] diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index 131af5e1c..9af4e120e 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -134,11 +134,12 @@ class ModuleIssue(ProjectBaseModel): module = models.ForeignKey( "db.Module", on_delete=models.CASCADE, related_name="issue_module" ) - issue = models.OneToOneField( + issue = models.ForeignKey( "db.Issue", on_delete=models.CASCADE, related_name="issue_module" ) class Meta: + unique_together = ["issue", "module"] verbose_name = "Module Issue" verbose_name_plural = "Module Issues" db_table = "module_issues" diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html index 4374846df..bdc6a53a3 100644 --- a/apiserver/templates/emails/notifications/issue-updates.html +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -1,978 +1,1108 @@ - - - - Updates on issue - - - -
- -
- - - - -
-
- -
-
-
- -
+ + + + Updates on issue + + + + + -
- - - - -
-

- {{ issue.issue_identifier }} updates -

-

- {{ issue.name }}: {{ issue.issue_identifier }} -

-
-
- -

- {% if data.1 %}{{ data|length }}{% endif %} {{ summary }} - - {{ data.0.actor_detail.first_name}} - {{data.0.actor_detail.last_name }} - -

- {% if comments.0 %} -

- {{ comments|length }} {% if comments|length == 1 %}comment was{% else %}comments were{% endif %} left by - - {% if comments|length == 1 %} - {{ data.0.actor_detail.first_name }} - {{ data.0.actor_detail.last_name }} - {% else %} - {{ data.0.actor_detail.first_name }} - {{ data.0.actor_detail.last_name }} and others - {% endif %} - -

- {% endif %} - {% if mentions and comments.0 and data.0 %} -

- There are 3 new updates, added 1 new comment and, you were - - @{{ data.0.actor_detail.first_name}} - {{data.0.actor_detail.last_name }} - - mentioned a comment of this issue. -

- {% endif %} - {% for update in data %} {% if update.changes.name %} - -

- The issue title has been updated from “{{update.changes.user.old_value.0}}“ to "{{update.changes.user.new_value|last}}" -

- {% endif %} - - {% if data %} -
- -
-

- Updates -

-
- -
- - - - + {/* first column/ issue name and key column */} -
- {% if update.actor_detail.avatar_url %} - - {% else %} - - - - - - -
+ +
+ + + - -
+
- - {{ update.actor_detail.first_name.0 }} - -
- {% endif %} + +
-

- {{ update.actor_detail.first_name }} {{ update.actor_detail.last_name }} -

-
-

- {{ update.activity_time }} -

-
- {% if update.changes.target_date %} - - - - - - - -
- - -
-

- Due Date: -

-
-
-

- {{ update.changes.target_date.new_value.0 }} -

-
- {% endif %} {% if update.changes.duplicate %} - - - - - - - - -
- - - Duplicate: - - - {% for duplicate in update.changes.duplicate.new_value %} - - {{ duplicate }} - - {% endfor %} -
- {% endif %} - - {% if update.changes.assignees %} - - - - - {% if update.changes.assignees.new_value.0 %} - - {% endif %} {% if update.changes.assignees.new_value.1 %} - - {% endif %} {% if update.changes.assignees.old_value.0 %} - - {% endif %} {% if update.changes.assignees.old_value.1 %} - - {% endif %} - -
- - -

- Assignees: -

-
-

- {{ update.changes.assignees.new_value.0 }} -

-
-

- +{{ update.changes.assignees.new_value|length|add:"-1"}} - more -

-
-

- {{update.changes.assignees.old_value.0}} -

-
-

- +{{ update.changes.assignees.old_value|length|add:"-1"}} - more -

-
- {% endif %} {% if update.changes.labels %} - - - - - - {% if update.changes.labels.new_value.0 %} - - {% endif %} - {% if update.changes.labels.new_value.1 %} - - {% endif %} - {% if update.changes.labels.old_value.0 %} - - {% endif %} - {% if update.changes.labels.old_value.1 %} - - {% endif %} - -
- - -

- Labels: -

-
-

- {{update.changes.labels.new_value.0}} -

-
-

- +{{ update.changes.labels.new_value|length|add:"-1"}} more -

-
-

- {{update.changes.labels.old_value.0}} -

-
-

- +{{ update.changes.labels.old_value|length|add:"-1"}} more -

-
- {% endif %} - - {% if update.changes.state %} - - - - - - - - - - -
- - -

- State: -

-
- - -

- {{ update.changes.state.old_value.0 }} -

-
- - - - -

- {{update.changes.state.new_value|last }} -

-
- {% endif %} {% if update.changes.link %} - - - - - - - -
- - -

- Links: -

-
- {% for link in update.changes.link.new_value %} - - {{ link }} - - {% endfor %} - {% if update.changes.link.old_value|length > 0 %} - {% if update.changes.link.old_value.0 != "None" %} -

- 2 Links were removed -

- {% endif %} - {% endif %} -
- {% endif %} - {% if update.changes.priority %} - - - - - - - - - - -
- - -

- Priority: -

-
-

- {{ update.changes.priority.old_value.0 }} -

-
- - -

- {{ update.changes.priority.new_value|last }} -

-
- {% endif %} - {% if update.changes.blocking.new_value %} - - - - - - - - -
- - - Blocking: - - - {% for blocking in update.changes.blocking.new_value %} - - {{blocking}} - - {% endfor %} -
- {% endif %} - - - {% endif %} - - {% endfor %} {% if comments.0 %} - -
- -

- Comments -

- - {% for comment in comments %} - - - - - +
- {% if comment.actor_detail.avatar_url %} - - {% else %} - - - - -
- - {{ comment.actor_detail.first_name.0 }} - -
- {% endif %} -
- - - - - {% for actor_comment in comment.actor_comments.new_value %} - - - - {% endfor %} -
-

- {{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }} -

-
-
-

- {{ actor_comment|safe }} -

-
-
-
- {% endfor %} -
- {% endif %} - - - - - - - - -
-
- This email was sent to - {{ receiver.email }}. - If you'd rather not receive this kind of email, - you can unsubscribe to the issue - or - manage your email preferences. - -
- - + + {% if actors_involved > 0 %} + {% if data|length > 0 and comments|length == 0 %} +

+ + {{ data.0.actor_detail.first_name}} + {{data.0.actor_detail.last_name }} + + made {{data|length}} {% if data|length > 1 %}updates{% else %}update{% endif %} to the issue. +

+ {% elif data|length == 0 and comments|length > 0 %} +

+ + {{ comments.0.actor_detail.first_name}} + {{comments.0.actor_detail.last_name }} + + added {{comments|length}} new {% if comments|length > 1 %}comments{% else %}comment{% endif %}. +

+ {% elif data|length > 0 and comments|length > 0 %} +

+ + {{ data.0.actor_detail.first_name}} + {{data.0.actor_detail.last_name }} + + made {{data|length}} {% if data|length > 1 %}updates{% else %}update{% endif %} and added {{comments|length}} new {% if comments|length > 1 %}comments{% else %}comment{% endif %} on the issue. +

+ {% endif %} + {% else %} +

+ There are {{ data|length }} new updates and {{comments|length}} new comments on the issue +

+ {% endif %} + {% for update in data %} {% if update.changes.name %} + +

+ The issue title has been updated to {{ issue.name}} +

+ {% endif %} + + {% if data %} +
+ +
+

+ Updates +

+
+ +
+ + + + + + + +
+ {% if update.actor_detail.avatar_url %} + + {% else %} + + + + +
+ + {{ update.actor_detail.first_name.0 }} + +
+ {% endif %} +
+

+ {{ update.actor_detail.first_name }} {{ update.actor_detail.last_name }} +

+
+

+ {{ update.activity_time }} +

+
+ {% if update.changes.target_date %} + + + + + + + +
+ + +
+

+ Due Date: +

+
+
+ {% if update.changes.target_date.new_value.0 %} +

+ {{ update.changes.target_date.new_value.0 }} +

+ {% else %} +

+ {{ update.changes.target_date.old_value.0 }} +

+ {% endif %} +
+ {% endif %} {% if update.changes.duplicate %} + + + + + {% if update.changes.duplicate.new_value.0 %} + + {% endif %} + {% if update.changes.duplicate.new_value.2 %} + + {% endif %} + {% if update.changes.duplicate.old_value.0 %} + + {% endif %} + {% if update.changes.duplicate.old_value.2 %} + + {% endif %} + +
+ + + Duplicate: + + + {% for duplicate in update.changes.duplicate.new_value|slice:":2" %} + + {{ duplicate }} + + {% endfor %} + + + +{{ update.changes.duplicate.new_value|length|add:"-2" }} + more + + + {% for duplicate in update.changes.duplicate.old_value|slice:":2" %} + + {{ duplicate }} + + {% endfor %} + + + +{{ update.changes.duplicate.old_value|length|add:"-2" }} + more + +
+ {% endif %} + + {% if update.changes.assignees %} + + + + + +
+ + + Assignee: + + + {% if update.changes.assignees.new_value.0 %} + + {{update.changes.assignees.new_value.0}} + + {% endif %} + {% if update.changes.assignees.new_value.1 %} + + +{{ update.changes.assignees.new_value|length|add:"-1"}} more + + {% endif %} + {% if update.changes.assignees.old_value.0 %} + + {{update.changes.assignees.old_value.0}} + + {% endif %} + {% if update.changes.assignees.old_value.1 %} + + +{{ update.changes.assignees.old_value|length|add:"-1"}} more + + {% endif %} +
+ {% endif %} {% if update.changes.labels %} + + + + + + +
+ + + Labels: + + + {% if update.changes.labels.new_value.0 %} + + {{update.changes.labels.new_value.0}} + + {% endif %} + {% if update.changes.labels.new_value.1 %} + + +{{ update.changes.labels.new_value|length|add:"-1"}} more + + {% endif %} + {% if update.changes.labels.old_value.0 %} + + {{update.changes.labels.old_value.0}} + + {% endif %} + {% if update.changes.labels.old_value.1 %} + + +{{ update.changes.labels.old_value|length|add:"-1"}} more + + {% endif %} +
+ {% endif %} + + {% if update.changes.state %} + + + + + + + + + + +
+ + +

+ State: +

+
+ + +

+ {{ update.changes.state.old_value.0 }} +

+
+ + + + +

+ {{update.changes.state.new_value|last }} +

+
+ {% endif %} {% if update.changes.link %} + + + + + + + +
+ + +

+ Links: +

+
+ {% for link in update.changes.link.new_value %} + + {{ link }} + + {% endfor %} + {% if update.changes.link.old_value|length > 0 %} + {% if update.changes.link.old_value.0 != "None" %} +

+ 2 Links were removed +

+ {% endif %} + {% endif %} +
+ {% endif %} + {% if update.changes.priority %} + + + + + + + + + +
+ + +

+ Priority: +

+
+

+ {{ update.changes.priority.old_value.0 }} +

+
+ + +

+ {{ update.changes.priority.new_value|last }} +

+
+ {% endif %} + {% if update.changes.blocking.new_value %} + + + + + {% if update.changes.blocking.new_value.0 %} + + {% endif %} + {% if update.changes.blocking.new_value.2 %} + + {% endif %} + {% if update.changes.blocking.old_value.0 %} + + {% endif %} + {% if update.changes.blocking.old_value.2 %} + + {% endif %} + +
+ + + Blocking: + + + {% for blocking in update.changes.blocking.new_value|slice:":2" %} + + {{ blocking }} + + {% endfor %} + + + +{{ update.changes.blocking.new_value|length|add:"-2" }} + more + + + {% for blocking in update.changes.blocking.old_value|slice:":2" %} + + {{ blocking }} + + {% endfor %} + + + +{{ update.changes.blocking.old_value|length|add:"-2" }} + more + +
+ {% endif %} +
+
+ {% endif %} + + {% endfor %} {% if comments.0 %} + +
+ +

+ Comments +

+ + {% for comment in comments %} + + + + + +
+ {% if comment.actor_detail.avatar_url %} + + {% else %} + + + + +
+ + {{ comment.actor_detail.first_name.0 }} + +
+ {% endif %} +
+ + + + + {% for actor_comment in comment.actor_comments.new_value %} + + + + {% endfor %} +
+

+ {{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }} +

+
+
+

+ {{ actor_comment|safe }} +

+
+
+
+ {% endfor %} +
+ {% endif %}
-
- - - + +
+ View issue +
+
+ + + + + + +
+
+ This email was sent to + {{ receiver.email }}. + If you'd rather not receive this kind of email, + you can unsubscribe to the issue + or + manage your email preferences. + + +
+
+ + + \ No newline at end of file diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 9734f85c2..527abe630 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -21,7 +21,7 @@ export type TIssue = { project_id: string; parent_id: string | null; cycle_id: string | null; - module_id: string | null; + module_ids: string[] | null; created_at: string; updated_at: string; diff --git a/packages/types/src/projects.d.ts b/packages/types/src/projects.d.ts index a412180b8..b54e3f0f9 100644 --- a/packages/types/src/projects.d.ts +++ b/packages/types/src/projects.d.ts @@ -117,7 +117,7 @@ export type TProjectIssuesSearchParams = { parent?: boolean; issue_relation?: boolean; cycle?: boolean; - module?: boolean; + module?: string[]; sub_issue?: boolean; issue_id?: string; workspace_search: boolean; diff --git a/web/components/account/sign-in-forms/optional-set-password.tsx b/web/components/account/sign-in-forms/optional-set-password.tsx index 1669811cb..d7a595298 100644 --- a/web/components/account/sign-in-forms/optional-set-password.tsx +++ b/web/components/account/sign-in-forms/optional-set-password.tsx @@ -8,6 +8,8 @@ import useToast from "hooks/use-toast"; import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; +// icons +import { Eye, EyeOff } from "lucide-react"; type Props = { email: string; @@ -31,6 +33,7 @@ export const SignInOptionalSetPasswordForm: React.FC = (props) => { const { email, handleSignInRedirection } = props; // states const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); + const [showPassword, setShowPassword] = useState(false); // toast alert const { setToastAlert } = useToast(); // form info @@ -114,17 +117,30 @@ export const SignInOptionalSetPasswordForm: React.FC = (props) => { required: "Password is required", }} render={({ field: { value, onChange, ref } }) => ( - +
+ + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
)} />

diff --git a/web/components/account/sign-in-forms/password.tsx b/web/components/account/sign-in-forms/password.tsx index fd4ccbf40..fe20d5b10 100644 --- a/web/components/account/sign-in-forms/password.tsx +++ b/web/components/account/sign-in-forms/password.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import Link from "next/link"; import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; -import { XCircle } from "lucide-react"; +import { Eye, EyeOff, XCircle } from "lucide-react"; // services import { AuthService } from "services/auth.service"; // hooks @@ -40,6 +40,7 @@ export const SignInPasswordForm: React.FC = observer((props) => { const { email, handleStepChange, handleEmailClear, onSubmit } = props; // states const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false); + const [showPassword, setShowPassword] = useState(false); // toast alert const { setToastAlert } = useToast(); const { @@ -157,15 +158,28 @@ export const SignInPasswordForm: React.FC = observer((props) => { required: "Password is required", }} render={({ field: { value, onChange } }) => ( - +

+ + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
)} />
diff --git a/web/components/account/sign-up-forms/optional-set-password.tsx b/web/components/account/sign-up-forms/optional-set-password.tsx index 38fdaeca1..db14f0ccb 100644 --- a/web/components/account/sign-up-forms/optional-set-password.tsx +++ b/web/components/account/sign-up-forms/optional-set-password.tsx @@ -10,6 +10,8 @@ import { Button, Input } from "@plane/ui"; import { checkEmailValidity } from "helpers/string.helper"; // constants import { ESignUpSteps } from "components/account"; +// icons +import { Eye, EyeOff } from "lucide-react"; type Props = { email: string; @@ -34,6 +36,7 @@ export const SignUpOptionalSetPasswordForm: React.FC = (props) => { const { email, handleSignInRedirection } = props; // states const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); + const [showPassword, setShowPassword] = useState(false); // toast alert const { setToastAlert } = useToast(); // form info @@ -119,16 +122,29 @@ export const SignUpOptionalSetPasswordForm: React.FC = (props) => { required: "Password is required", }} render={({ field: { value, onChange } }) => ( - +
+ + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
)} />

diff --git a/web/components/account/sign-up-forms/password.tsx b/web/components/account/sign-up-forms/password.tsx index 6ff6753df..293e03ef8 100644 --- a/web/components/account/sign-up-forms/password.tsx +++ b/web/components/account/sign-up-forms/password.tsx @@ -1,8 +1,8 @@ -import React from "react"; +import React, { useState } from "react"; import Link from "next/link"; import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; -import { XCircle } from "lucide-react"; +import { Eye, EyeOff, XCircle } from "lucide-react"; // services import { AuthService } from "services/auth.service"; // hooks @@ -32,6 +32,8 @@ const authService = new AuthService(); export const SignUpPasswordForm: React.FC = observer((props) => { const { onSubmit } = props; + // states + const [showPassword, setShowPassword] = useState(false); // toast alert const { setToastAlert } = useToast(); // form info @@ -112,15 +114,28 @@ export const SignUpPasswordForm: React.FC = observer((props) => { required: "Password is required", }} render={({ field: { value, onChange } }) => ( - +

+ + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
)} />

diff --git a/web/components/command-palette/command-palette.tsx b/web/components/command-palette/command-palette.tsx index 6a550e0ad..213c35f8e 100644 --- a/web/components/command-palette/command-palette.tsx +++ b/web/components/command-palette/command-palette.tsx @@ -216,7 +216,7 @@ export const CommandPalette: FC = observer(() => { toggleCreateIssueModal(false)} - data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_id: moduleId.toString() } : undefined} + data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined} storeType={createIssueStoreType} /> diff --git a/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx new file mode 100644 index 000000000..0e34eac2c --- /dev/null +++ b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx @@ -0,0 +1,16 @@ +import { FC } from "react"; +import { Menu } from "lucide-react"; +import { useApplication } from "hooks/store"; +import { observer } from "mobx-react"; + +export const SidebarHamburgerToggle: FC = observer (() => { + const { theme: themStore } = useApplication(); + return ( +

themStore.toggleSidebar()} + > + +
+ ); +}); diff --git a/web/components/dashboard/widgets/overview-stats.tsx b/web/components/dashboard/widgets/overview-stats.tsx index 3af080dc3..418f0c63f 100644 --- a/web/components/dashboard/widgets/overview-stats.tsx +++ b/web/components/dashboard/widgets/overview-stats.tsx @@ -63,34 +63,27 @@ export const OverviewStatsWidget: React.FC = observer((props) => { if (!widgetStats) return ; return ( -
- {STATS_LIST.map((stat, index) => { - const isFirst = index === 0; - const isLast = index === STATS_LIST.length - 1; - const isMiddle = !isFirst && !isLast; - - return ( -
- {!isLast && ( -
- )} - -
{stat.count}
-

{stat.title}

- -
- ); - })} +
+ {STATS_LIST.map((stat) => ( +
+ +
+
+
{stat.count}
+

{stat.title}

+
+
+ +
+ ))}
); }); diff --git a/web/components/dropdowns/index.ts b/web/components/dropdowns/index.ts index 036ed9f75..53be7e4f5 100644 --- a/web/components/dropdowns/index.ts +++ b/web/components/dropdowns/index.ts @@ -3,6 +3,7 @@ export * from "./cycle"; export * from "./date"; export * from "./estimate"; export * from "./module"; +export * from "./module-select"; export * from "./priority"; export * from "./project"; export * from "./state"; diff --git a/web/components/dropdowns/module-select/button.tsx b/web/components/dropdowns/module-select/button.tsx new file mode 100644 index 000000000..85c97d449 --- /dev/null +++ b/web/components/dropdowns/module-select/button.tsx @@ -0,0 +1,114 @@ +import { FC } from "react"; +import { twMerge } from "tailwind-merge"; +import { observer } from "mobx-react-lite"; +import { ChevronDown, X } from "lucide-react"; +// hooks +import { useModule } from "hooks/store"; +// ui and components +import { DiceIcon, Tooltip } from "@plane/ui"; +// types +import { TModuleSelectButton } from "./types"; + +export const ModuleSelectButton: FC = observer((props) => { + const { + value, + onChange, + placeholder, + buttonClassName, + buttonVariant, + hideIcon, + hideText, + dropdownArrow, + dropdownArrowClassName, + showTooltip, + showCount, + } = props; + // hooks + const { getModuleById } = useModule(); + + return ( +
+
+ {value && typeof value === "string" ? ( +
+ {!hideIcon && } + {!hideText && ( + + {getModuleById(value)?.name || placeholder} + + )} +
+ ) : value && Array.isArray(value) && value.length > 0 ? ( + showCount ? ( +
+ {!hideIcon && } + {!hideText && ( + + {value.length} Modules + + )} +
+ ) : ( + value.map((moduleId) => { + const _module = getModuleById(moduleId); + if (!_module) return <>; + return ( +
+ +
+ {!hideIcon && } + {!hideText && ( + {_module?.name} + )} +
+
+ + { + e.preventDefault(); + e.stopPropagation(); + onChange(_module.id); + }} + > + + + +
+ ); + }) + ) + ) : ( + !hideText && ( +
+ {!hideIcon && } + {!hideText && ( + + {placeholder} + + )} +
+ ) + )} +
+ + {dropdownArrow && ( +
+ ); +}); diff --git a/web/components/dropdowns/module-select/index.ts b/web/components/dropdowns/module-select/index.ts new file mode 100644 index 000000000..2161534fb --- /dev/null +++ b/web/components/dropdowns/module-select/index.ts @@ -0,0 +1,2 @@ +export * from "./button"; +export * from "./select"; diff --git a/web/components/dropdowns/module-select/select.tsx b/web/components/dropdowns/module-select/select.tsx new file mode 100644 index 000000000..a8ddfccf7 --- /dev/null +++ b/web/components/dropdowns/module-select/select.tsx @@ -0,0 +1,227 @@ +import { FC, useEffect, useRef, useState, Fragment } from "react"; +import { observer } from "mobx-react-lite"; +import { Combobox } from "@headlessui/react"; +import { usePopper } from "react-popper"; +import { Check, Search } from "lucide-react"; +import { twMerge } from "tailwind-merge"; +// hooks +import { useModule } from "hooks/store"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +// components +import { ModuleSelectButton } from "./"; +// types +import { TModuleSelectDropdown, TModuleSelectDropdownOption } from "./types"; +import { DiceIcon } from "@plane/ui"; + +export const ModuleSelectDropdown: FC = observer((props) => { + // props + const { + workspaceSlug, + projectId, + value = undefined, + onChange, + placeholder = "Module", + multiple = false, + disabled = false, + className = "", + buttonContainerClassName = "", + buttonClassName = "", + buttonVariant = "transparent-with-text", + hideIcon = false, + dropdownArrow = false, + dropdownArrowClassName = "", + showTooltip = false, + showCount = false, + placement, + tabIndex, + button, + } = props; + // states + const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + // store hooks + const { getProjectModuleIds, fetchModules, getModuleById } = useModule(); + + const moduleIds = getProjectModuleIds(projectId); + + const options: TModuleSelectDropdownOption[] | undefined = moduleIds?.map((moduleId) => { + const moduleDetails = getModuleById(moduleId); + return { + value: moduleId, + query: `${moduleDetails?.name}`, + content: ( +
+ + {moduleDetails?.name} +
+ ), + }; + }); + !multiple && + options?.unshift({ + value: undefined, + query: "No module", + content: ( +
+ + No module +
+ ), + }); + + const filteredOptions = + query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); + + // fetch modules of the project if not already present in the store + useEffect(() => { + if (!workspaceSlug) return; + + if (!moduleIds) fetchModules(workspaceSlug, projectId); + }, [moduleIds, fetchModules, projectId, workspaceSlug]); + + const openDropdown = () => { + if (isOpen) closeDropdown(); + else { + setIsOpen(true); + if (referenceElement) referenceElement.focus(); + } + }; + const closeDropdown = () => setIsOpen(false); + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + useOutsideClickDetector(dropdownRef, closeDropdown); + + const comboboxProps: any = {}; + if (multiple) comboboxProps.multiple = true; + + return ( + + + {button ? ( + + ) : ( + + )} + + {isOpen && ( + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(moduleIds: any) => { + const displayValueOptions: TModuleSelectDropdownOption[] | undefined = options?.filter((_module) => + moduleIds.includes(_module.value) + ); + return displayValueOptions?.map((_option) => _option.query).join(", ") || "Select Module"; + }} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + onClick={() => !multiple && closeDropdown()} + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

No matching results

+ ) + ) : ( +

Loading...

+ )} +
+
+
+ )} +
+ ); +}); diff --git a/web/components/dropdowns/module-select/types.d.ts b/web/components/dropdowns/module-select/types.d.ts new file mode 100644 index 000000000..b1c10eedb --- /dev/null +++ b/web/components/dropdowns/module-select/types.d.ts @@ -0,0 +1,50 @@ +import { ReactNode } from "react"; +import { Placement } from "@popperjs/core"; +import { TDropdownProps, TButtonVariants } from "../types"; + +type TModuleSelectDropdownRoot = Omit< + TDropdownProps, + "buttonClassName", + "buttonContainerClassName", + "buttonContainerClassName", + "className", + "disabled", + "hideIcon", + "placeholder", + "placement", + "tabIndex", + "tooltip" +>; + +export type TModuleSelectDropdownBase = { + value: string | string[] | undefined; + onChange: (moduleIds: undefined | string | (string | undefined)[]) => void; + placeholder?: string; + disabled?: boolean; + buttonClassName?: string; + buttonVariant?: TButtonVariants; + hideIcon?: boolean; + dropdownArrow?: boolean; + dropdownArrowClassName?: string; + showTooltip?: boolean; + showCount?: boolean; +}; + +export type TModuleSelectButton = TModuleSelectDropdownBase & { hideText?: boolean }; + +export type TModuleSelectDropdown = TModuleSelectDropdownBase & { + workspaceSlug: string; + projectId: string; + multiple?: boolean; + className?: string; + buttonContainerClassName?: string; + placement?: Placement; + tabIndex?: number; + button?: ReactNode; +}; + +export type TModuleSelectDropdownOption = { + value: string | undefined; + query: string; + content: JSX.Element; +}; diff --git a/web/components/gantt-chart/blocks/blocks-display.tsx b/web/components/gantt-chart/blocks/blocks-display.tsx index 02d2eb865..e13be116b 100644 --- a/web/components/gantt-chart/blocks/blocks-display.tsx +++ b/web/components/gantt-chart/blocks/blocks-display.tsx @@ -1,9 +1,11 @@ import { FC } from "react"; // hooks +import { useIssueDetail } from "hooks/store"; import { useChart } from "../hooks"; // helpers import { ChartAddBlock, ChartDraggable } from "components/gantt-chart"; import { renderFormattedPayloadDate } from "helpers/date-time.helper"; +import { cn } from "helpers/common.helper"; // types import { IBlockUpdateData, IGanttBlock } from "../types"; @@ -31,6 +33,7 @@ export const GanttChartBlocks: FC = (props) => { } = props; const { activeBlock, dispatch } = useChart(); + const { peekIssue } = useIssueDetail(); // update the active block on hover const updateActiveBlock = (block: IGanttBlock | null) => { @@ -88,7 +91,14 @@ export const GanttChartBlocks: FC = (props) => { return (
updateActiveBlock(block)} onMouseLeave={() => updateActiveBlock(null)} > diff --git a/web/components/gantt-chart/sidebar/sidebar.tsx b/web/components/gantt-chart/sidebar/sidebar.tsx index 062b76451..bca39a0bd 100644 --- a/web/components/gantt-chart/sidebar/sidebar.tsx +++ b/web/components/gantt-chart/sidebar/sidebar.tsx @@ -3,12 +3,14 @@ import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea import { MoreVertical } from "lucide-react"; // hooks import { useChart } from "components/gantt-chart/hooks"; +import { useIssueDetail } from "hooks/store"; // ui import { Loader } from "@plane/ui"; // components import { GanttQuickAddIssueForm, IssueGanttSidebarBlock } from "components/issues"; // helpers import { findTotalDaysInRange } from "helpers/date-time.helper"; +import { cn } from "helpers/common.helper"; // types import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types"; import { TIssue } from "@plane/types"; @@ -45,6 +47,7 @@ export const IssueGanttSidebar: React.FC = (props) => { const { cycleId } = router.query; const { activeBlock, dispatch } = useChart(); + const { peekIssue } = useIssueDetail(); // update the active block on hover const updateActiveBlock = (block: IGanttBlock | null) => { @@ -104,7 +107,7 @@ export const IssueGanttSidebar: React.FC = (props) => { {(droppableProvided) => (
@@ -130,7 +133,14 @@ export const IssueGanttSidebar: React.FC = (props) => { > {(provided, snapshot) => (
updateActiveBlock(block)} onMouseLeave={() => updateActiveBlock(null)} ref={provided.innerRef} diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index 949c192fa..fc0075030 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -17,6 +17,7 @@ import useLocalStorage from "hooks/use-local-storage"; // components import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; import { ProjectAnalyticsModal } from "components/analytics"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; // ui import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui"; // icons @@ -146,6 +147,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { />
+ { // router @@ -30,6 +32,7 @@ export const CyclesHeader: FC = observer(() => { return (
+
= observer((props) => { <> setCreateViewModal(false)} />
-
+
+ { />
+ { // router @@ -31,6 +33,7 @@ export const ModulesListHeader: React.FC = observer(() => { return (
+
= observer((props) => { return (
+
{ // router @@ -29,6 +31,7 @@ export const PagesHeader = observer(() => { return (
+
{ return (
+
{ return (
+
) : activityTab === "activity" ? ( @@ -166,14 +162,11 @@ export const IssueActivity: FC = observer((props) => { activityOperations={activityOperations} showAccessSpecifier={project.is_deployed} /> - {!disabled && ( - - )} +
)}
diff --git a/web/components/issues/issue-detail/main-content.tsx b/web/components/issues/issue-detail/main-content.tsx index 31400fde2..075525801 100644 --- a/web/components/issues/issue-detail/main-content.tsx +++ b/web/components/issues/issue-detail/main-content.tsx @@ -99,7 +99,7 @@ export const IssueMainContent: React.FC = observer((props) => { disabled={!is_editable} /> - + ); }); diff --git a/web/components/issues/issue-detail/module-select.tsx b/web/components/issues/issue-detail/module-select.tsx index 06853a799..82ff4ed32 100644 --- a/web/components/issues/issue-detail/module-select.tsx +++ b/web/components/issues/issue-detail/module-select.tsx @@ -1,9 +1,10 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; +import xor from "lodash/xor"; // hooks import { useIssueDetail } from "hooks/store"; // components -import { ModuleDropdown } from "components/dropdowns"; +import { ModuleSelectDropdown } from "components/dropdowns"; // ui import { Spinner } from "@plane/ui"; // helpers @@ -32,58 +33,56 @@ export const IssueModuleSelect: React.FC = observer((props) const issue = getIssueById(issueId); const disableSelect = disabled || isUpdating; - const handleIssueModuleChange = async (moduleId: string | null) => { - if (!issue || issue.module_id === moduleId) return; + const handleIssueModuleChange = async (moduleIds: undefined | string | (string | undefined)[]) => { + if (!issue) return; + setIsUpdating(true); - if (moduleId) await issueOperations.addIssueToModule?.(workspaceSlug, projectId, moduleId, [issueId]); - else await issueOperations.removeIssueFromModule?.(workspaceSlug, projectId, issue.module_id ?? "", issueId); + if (moduleIds === undefined && issue?.module_ids && issue?.module_ids.length > 0) + await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, issue?.module_ids); + + if (typeof moduleIds === "string" && moduleIds) + await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, [moduleIds]); + + if (Array.isArray(moduleIds)) { + if (moduleIds.includes(undefined)) { + await issueOperations.removeModulesFromIssue?.( + workspaceSlug, + projectId, + issueId, + moduleIds.filter((x) => x != undefined) as string[] + ); + } else { + const _moduleIds = xor(issue?.module_ids, moduleIds)[0]; + if (_moduleIds) { + if (issue?.module_ids?.includes(_moduleIds)) + await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, [_moduleIds]); + else await issueOperations.addModulesToIssue?.(workspaceSlug, projectId, issueId, [_moduleIds]); + } + } + } setIsUpdating(false); }; return ( -
- + - {/* handleIssueModuleChange(value)} - options={options} - customButton={ -
- - - -
- } - noChevron disabled={disableSelect} - /> */} + className={`w-full h-full group`} + buttonContainerClassName="w-full" + buttonClassName={`min-h-8 ${issue?.module_ids?.length ? `` : `text-custom-text-400`}`} + buttonVariant="transparent-with-text" + hideIcon={false} + dropdownArrow={true} + dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" + showTooltip={true} + /> + {isUpdating && }
); diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx index 58a52cc97..9a16dcbf0 100644 --- a/web/components/issues/issue-detail/root.tsx +++ b/web/components/issues/issue-detail/root.tsx @@ -29,13 +29,19 @@ export type TIssueOperations = { remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; addIssueToCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; - addIssueToModule?: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise; + addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise; removeIssueFromModule?: ( workspaceSlug: string, projectId: string, moduleId: string, issueId: string ) => Promise; + removeModulesFromIssue?: ( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleIds: string[] + ) => Promise; }; export type TIssueDetailRoot = { @@ -57,8 +63,9 @@ export const IssueDetailRoot: FC = (props) => { removeIssue, addIssueToCycle, removeIssueFromCycle, - addIssueToModule, + addModulesToIssue, removeIssueFromModule, + removeModulesFromIssue, } = useIssueDetail(); const { issues: { removeIssue: removeArchivedIssue }, @@ -150,9 +157,9 @@ export const IssueDetailRoot: FC = (props) => { }); } }, - addIssueToModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { + addModulesToIssue: async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { try { - await addIssueToModule(workspaceSlug, projectId, moduleId, issueIds); + await addModulesToIssue(workspaceSlug, projectId, issueId, moduleIds); setToastAlert({ title: "Module added to issue successfully", type: "success", @@ -182,6 +189,27 @@ export const IssueDetailRoot: FC = (props) => { }); } }, + removeModulesFromIssue: async ( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleIds: string[] + ) => { + try { + await removeModulesFromIssue(workspaceSlug, projectId, issueId, moduleIds); + setToastAlert({ + title: "Module removed from issue successfully", + type: "success", + message: "Module removed from issue successfully", + }); + } catch (error) { + setToastAlert({ + title: "Module remove from issue failed", + type: "error", + message: "Module remove from issue failed", + }); + } + }, }), [ is_archived, @@ -191,8 +219,9 @@ export const IssueDetailRoot: FC = (props) => { removeArchivedIssue, addIssueToCycle, removeIssueFromCycle, - addIssueToModule, + addModulesToIssue, removeIssueFromModule, + removeModulesFromIssue, setToastAlert, ] ); diff --git a/web/components/issues/issue-detail/sidebar.tsx b/web/components/issues/issue-detail/sidebar.tsx index 0a38c3017..f2ee876b9 100644 --- a/web/components/issues/issue-detail/sidebar.tsx +++ b/web/components/issues/issue-detail/sidebar.tsx @@ -286,7 +286,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { )} {projectDetails?.module_view && ( -
+
Module diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index 5711c89f6..f66bf2ec0 100644 --- a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -6,7 +6,8 @@ import { MoreHorizontal } from "lucide-react"; import { Tooltip, ControlLink } from "@plane/ui"; // hooks import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// ui +// helpers +import { cn } from "helpers/common.helper"; // types import { TIssue, TIssueMap } from "@plane/types"; import { useApplication, useIssueDetail, useProject, useProjectState } from "hooks/store"; @@ -26,7 +27,7 @@ export const CalendarIssueBlocks: React.FC = observer((props) => { } = useApplication(); const { getProjectById } = useProject(); const { getProjectStates } = useProjectState(); - const { setPeekIssue } = useIssueDetail(); + const { peekIssue, setPeekIssue } = useIssueDetail(); // states const [isMenuActive, setIsMenuActive] = useState(false); @@ -84,11 +85,18 @@ export const CalendarIssueBlocks: React.FC = observer((props) => { )}
= observer((props) => { const issueIds = data.map((i) => i.id); await issues - .addIssueToModule(workspaceSlug.toString(), projectId?.toString(), moduleId.toString(), issueIds) - .then((res) => { - updateIssue(workspaceSlug, projectId, res.id, res); - fetchIssue(workspaceSlug, projectId, res.id); - }) + .addIssuesToModule(workspaceSlug.toString(), projectId?.toString(), moduleId.toString(), issueIds) .catch(() => setToastAlert({ type: "error", @@ -69,7 +65,7 @@ export const ModuleEmptyState: React.FC = observer((props) => { projectId={projectId} isOpen={moduleIssuesListModal} handleClose={() => setModuleIssuesListModal(false)} - searchParams={{ module: true }} + searchParams={{ module: moduleId != undefined ? [moduleId.toString()] : [] }} handleOnSubmit={handleAddIssuesToModule} />
diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 5a94b6bac..eb7005cbd 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -44,7 +44,7 @@ export interface IBaseKanBanLayout { showLoader?: boolean; viewId?: string; storeType?: TCreateModalStoreTypes; - addIssuesToView?: (issueIds: string[]) => Promise; + addIssuesToView?: (issueIds: string[]) => Promise; canEditPropertiesBasedOnProject?: (projectId: string) => boolean; } diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index d1ab82404..68b09135c 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -1,6 +1,8 @@ import { memo } from "react"; import { Draggable, DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; import { observer } from "mobx-react-lite"; +// hooks +import { useApplication, useIssueDetail, useProject } from "hooks/store"; // components import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; import { IssueProperties } from "../properties/all-properties"; @@ -9,9 +11,11 @@ import { Tooltip, ControlLink } from "@plane/ui"; // types import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; import { EIssueActions } from "../types"; -import { useApplication, useIssueDetail, useProject } from "hooks/store"; +// helper +import { cn } from "helpers/common.helper"; interface IssueBlockProps { + peekIssueId?: string; issueId: string; issuesMap: IIssueMap; displayProperties: IIssueDisplayProperties | undefined; @@ -86,6 +90,7 @@ const KanbanIssueDetailsBlock: React.FC = observer((prop export const KanbanIssueBlock: React.FC = memo((props) => { const { + peekIssueId, issueId, issuesMap, displayProperties, @@ -121,9 +126,12 @@ export const KanbanIssueBlock: React.FC = memo((props) => {
)}
= (props) => { sub_group_id, columnId, issuesMap, + peekIssueId, issueIds, displayProperties, isDragDisabled, @@ -44,6 +46,7 @@ const KanbanIssueBlocksListMemo: React.FC = (props) => { return ( = observer((props) => { const project = useProject(); const label = useLabel(); const projectState = useProjectState(); + const { peekIssue } = useIssueDetail(); const list = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member); @@ -120,6 +121,7 @@ const GroupByKanBan: React.FC = observer((props) => { groupId={_list.id} issuesMap={issuesMap} issueIds={issueIds} + peekIssueId={peekIssue?.issueId ?? ""} displayProperties={displayProperties} sub_group_by={sub_group_by} group_by={group_by} diff --git a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx index 05c2b5d45..713a6644a 100644 --- a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -56,7 +56,7 @@ export const HeaderGroupByCard: FC = observer((props) => { const { setToastAlert } = useToast(); const renderExistingIssueModal = moduleId || cycleId; - const ExistingIssuesListModalPayload = moduleId ? { module: true } : { cycle: true }; + const ExistingIssuesListModalPayload = moduleId ? { module: [moduleId.toString()] } : { cycle: true }; const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => { if (!workspaceSlug || !projectId) return; diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index 76d456b5e..1a25c563e 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -17,6 +17,7 @@ import { EIssueActions } from "../types"; interface IKanbanGroup { groupId: string; issuesMap: IIssueMap; + peekIssueId?: string; issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; displayProperties: IIssueDisplayProperties | undefined; sub_group_by: string | null; @@ -47,6 +48,7 @@ export const KanbanGroup = (props: IKanbanGroup) => { issuesMap, displayProperties, issueIds, + peekIssueId, isDragDisabled, handleIssues, quickActions, @@ -118,6 +120,7 @@ export const KanbanGroup = (props: IKanbanGroup) => { sub_group_id={sub_group_id} columnId={groupId} issuesMap={issuesMap} + peekIssueId={peekIssueId} issueIds={(issueIds as TGroupedIssues)?.[groupId] || []} displayProperties={displayProperties} isDragDisabled={isDragDisabled} diff --git a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx index 89f4683af..c3af69e6e 100644 --- a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx @@ -53,7 +53,7 @@ export const ModuleKanBanLayout: React.FC = observer(() => { storeType={EIssuesStoreType.MODULE} addIssuesToView={(issueIds: string[]) => { if (!workspaceSlug || !projectId || !moduleId) throw new Error(); - return issues.addIssueToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds); + return issues.addIssuesToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds); }} /> ); diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index b718269b6..10f3582f1 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -49,7 +49,7 @@ interface IBaseListRoot { }; viewId?: string; storeType: TCreateModalStoreTypes; - addIssuesToView?: (issueIds: string[]) => Promise; + addIssuesToView?: (issueIds: string[]) => Promise; canEditPropertiesBasedOnProject?: (projectId: string) => boolean; } diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index da63a834e..b2222a69e 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -5,6 +5,8 @@ import { IssueProperties } from "../properties/all-properties"; import { useApplication, useIssueDetail, useProject } from "hooks/store"; // ui import { Spinner, Tooltip, ControlLink } from "@plane/ui"; +// helper +import { cn } from "helpers/common.helper"; // types import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types"; import { EIssueActions } from "../types"; @@ -25,7 +27,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock router: { workspaceSlug, projectId }, } = useApplication(); const { getProjectById } = useProject(); - const { setPeekIssue } = useIssueDetail(); + const { peekIssue, setPeekIssue } = useIssueDetail(); const updateIssue = (issueToUpdate: TIssue) => { handleIssues(issueToUpdate, EIssueActions.UPDATE); @@ -47,7 +49,15 @@ export const IssueBlock: React.FC = observer((props: IssueBlock return ( <> -
+
{displayProperties && displayProperties?.key && (
{projectDetails?.identifier}-{issue.sequence_id} diff --git a/web/components/issues/issue-layouts/list/blocks-list.tsx b/web/components/issues/issue-layouts/list/blocks-list.tsx index 729cd6c68..5e02d638f 100644 --- a/web/components/issues/issue-layouts/list/blocks-list.tsx +++ b/web/components/issues/issue-layouts/list/blocks-list.tsx @@ -18,7 +18,7 @@ export const IssueBlocksList: FC = (props) => { const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties } = props; return ( -
+
{issueIds && issueIds.length > 0 ? ( issueIds.map((issueId: string) => { if (!issueId) return null; diff --git a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx index cf56d6b5d..7a7a2d1ab 100644 --- a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -37,7 +37,7 @@ export const HeaderGroupByCard = observer( const { setToastAlert } = useToast(); const renderExistingIssueModal = moduleId || cycleId; - const ExistingIssuesListModalPayload = moduleId ? { module: true } : { cycle: true }; + const ExistingIssuesListModalPayload = moduleId ? { module: [moduleId.toString()] } : { cycle: true }; const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => { if (!workspaceSlug || !projectId) return; diff --git a/web/components/issues/issue-layouts/list/roots/module-root.tsx b/web/components/issues/issue-layouts/list/roots/module-root.tsx index fb874b8f6..520a2da32 100644 --- a/web/components/issues/issue-layouts/list/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/module-root.tsx @@ -51,7 +51,7 @@ export const ModuleListLayout: React.FC = observer(() => { storeType={EIssuesStoreType.MODULE} addIssuesToView={(issueIds: string[]) => { if (!workspaceSlug || !projectId || !moduleId) throw new Error(); - return issues.addIssueToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds); + return issues.addIssuesToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds); }} /> ); diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index b7e1fae4f..c23938a19 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -155,7 +155,6 @@ export const IssueProperties: React.FC = observer((props) => { multiple buttonVariant={issue.assignee_ids?.length > 0 ? "transparent-without-text" : "border-without-text"} buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""} - tooltip />
diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 602c1a842..579b8863c 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -12,6 +12,8 @@ import { ControlLink, Tooltip } from "@plane/ui"; // hooks import useOutsideClickDetector from "hooks/use-outside-click-detector"; import { useIssueDetail, useProject } from "hooks/store"; +// helper +import { cn } from "helpers/common.helper"; // types import { IIssueDisplayProperties, TIssue } from "@plane/types"; import { EIssueActions } from "../types"; @@ -48,7 +50,7 @@ export const SpreadsheetIssueRow = observer((props: Props) => { const { workspaceSlug } = router.query; //hooks const { getProjectById } = useProject(); - const { setPeekIssue } = useIssueDetail(); + const { peekIssue, setPeekIssue } = useIssueDetail(); // states const [isMenuActive, setIsMenuActive] = useState(false); const [isExpanded, setExpanded] = useState(false); @@ -95,9 +97,20 @@ export const SpreadsheetIssueRow = observer((props: Props) => { return ( <> -
+
{ } = props; return ( - +
= { assignee_ids: [], label_ids: [], cycle_id: null, - module_id: null, + module_ids: null, start_date: null, target_date: null, }; @@ -541,21 +541,24 @@ export const IssueFormRoot: FC = observer((props) => { )} /> )} - {projectDetails?.module_view && ( + {projectDetails?.module_view && workspaceSlug && ( (
- { onChange(moduleId); handleFormChange(); }} buttonVariant="border-with-text" tabIndex={13} + multiple={true} + showCount={true} />
)} diff --git a/web/components/issues/issue-modal/modal.tsx b/web/components/issues/issue-modal/modal.tsx index c3c38c572..da13e6353 100644 --- a/web/components/issues/issue-modal/modal.tsx +++ b/web/components/issues/issue-modal/modal.tsx @@ -108,11 +108,11 @@ export const CreateUpdateIssueModal: React.FC = observer((prop fetchCycleDetails(workspaceSlug, activeProjectId, cycleId); }; - const addIssueToModule = async (issue: TIssue, moduleId: string) => { + const addIssueToModule = async (issue: TIssue, moduleIds: string[]) => { if (!workspaceSlug || !activeProjectId) return; - await moduleIssues.addIssueToModule(workspaceSlug, activeProjectId, moduleId, [issue.id]); - fetchModuleDetails(workspaceSlug, activeProjectId, moduleId); + await moduleIssues.addModulesToIssue(workspaceSlug, activeProjectId, issue.id, moduleIds); + moduleIds.forEach((moduleId) => fetchModuleDetails(workspaceSlug, activeProjectId, moduleId)); }; const handleCreateMoreToggleChange = (value: boolean) => { @@ -139,8 +139,8 @@ export const CreateUpdateIssueModal: React.FC = observer((prop if (payload.cycle_id && payload.cycle_id !== "" && storeType !== EIssuesStoreType.CYCLE) await addIssueToCycle(response, payload.cycle_id); - if (payload.module_id && payload.module_id !== "" && storeType !== EIssuesStoreType.MODULE) - await addIssueToModule(response, payload.module_id); + if (payload.module_ids && payload.module_ids.length > 0 && storeType !== EIssuesStoreType.MODULE) + await addIssueToModule(response, payload.module_ids); setToastAlert({ type: "success", @@ -278,7 +278,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop data={{ ...data, cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId : null, - module_id: data?.module_id ? data?.module_id : moduleId ? moduleId : null, + module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId] : null, }} onChange={handleFormChange} onClose={handleClose} @@ -292,7 +292,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop data={{ ...data, cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId : null, - module_id: data?.module_id ? data?.module_id : moduleId ? moduleId : null, + module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId] : null, }} onClose={() => handleClose(false)} isCreateMoreToggleEnabled={createMore} diff --git a/web/components/issues/peek-overview/properties.tsx b/web/components/issues/peek-overview/properties.tsx index 6aee23a23..ea00b845a 100644 --- a/web/components/issues/peek-overview/properties.tsx +++ b/web/components/issues/peek-overview/properties.tsx @@ -203,7 +203,7 @@ export const PeekOverviewProperties: FC = observer((pro )} {projectDetails?.module_view && ( -
+
Module diff --git a/web/components/issues/peek-overview/root.tsx b/web/components/issues/peek-overview/root.tsx index 89a659fb3..5041e5d2a 100644 --- a/web/components/issues/peek-overview/root.tsx +++ b/web/components/issues/peek-overview/root.tsx @@ -28,8 +28,19 @@ export type TIssuePeekOperations = { remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; - addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise; - removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise; + addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise; + removeIssueFromModule?: ( + workspaceSlug: string, + projectId: string, + moduleId: string, + issueId: string + ) => Promise; + removeModulesFromIssue?: ( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleIds: string[] + ) => Promise; }; export const IssuePeekOverview: FC = observer((props) => { @@ -48,7 +59,8 @@ export const IssuePeekOverview: FC = observer((props) => { removeIssue, issue: { getIssueById, fetchIssue }, } = useIssueDetail(); - const { addIssueToCycle, removeIssueFromCycle, addIssueToModule, removeIssueFromModule } = useIssueDetail(); + const { addIssueToCycle, removeIssueFromCycle, addModulesToIssue, removeIssueFromModule, removeModulesFromIssue } = + useIssueDetail(); // state const [loader, setLoader] = useState(false); @@ -143,9 +155,9 @@ export const IssuePeekOverview: FC = observer((props) => { }); } }, - addIssueToModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { + addModulesToIssue: async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { try { - await addIssueToModule(workspaceSlug, projectId, moduleId, issueIds); + await addModulesToIssue(workspaceSlug, projectId, issueId, moduleIds); setToastAlert({ title: "Module added to issue successfully", type: "success", @@ -175,6 +187,27 @@ export const IssuePeekOverview: FC = observer((props) => { }); } }, + removeModulesFromIssue: async ( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleIds: string[] + ) => { + try { + await removeModulesFromIssue(workspaceSlug, projectId, issueId, moduleIds); + setToastAlert({ + title: "Module removed from issue successfully", + type: "success", + message: "Module removed from issue successfully", + }); + } catch (error) { + setToastAlert({ + title: "Module remove from issue failed", + type: "error", + message: "Module remove from issue failed", + }); + } + }, }), [ is_archived, @@ -184,8 +217,9 @@ export const IssuePeekOverview: FC = observer((props) => { removeArchivedIssue, addIssueToCycle, removeIssueFromCycle, - addIssueToModule, + addModulesToIssue, removeIssueFromModule, + removeModulesFromIssue, setToastAlert, onIssueUpdate, ] diff --git a/web/components/issues/peek-overview/view.tsx b/web/components/issues/peek-overview/view.tsx index 5d2e486e6..82bda41d5 100644 --- a/web/components/issues/peek-overview/view.tsx +++ b/web/components/issues/peek-overview/view.tsx @@ -234,7 +234,6 @@ export const IssueView: FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} - disabled={disabled} />
) : ( @@ -255,7 +254,6 @@ export const IssueView: FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} - disabled={disabled} />
diff --git a/web/components/project/sidebar-list-item.tsx b/web/components/project/sidebar-list-item.tsx index 1a5153884..8e4188d81 100644 --- a/web/components/project/sidebar-list-item.tsx +++ b/web/components/project/sidebar-list-item.tsx @@ -131,6 +131,12 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { setLeaveProjectModal(false); }; + const handleProjectClick = () => { + if (window.innerWidth < 768) { + themeStore.toggleSidebar(); + } + }; + useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); if (!project) return null; @@ -143,9 +149,8 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { {({ open }) => ( <>
{provided && ( = observer((props) => { >