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/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/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/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/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/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx index fdbfb30f7..a5b0ef149 100644 --- a/web/components/issues/draft-issue-form.tsx +++ b/web/components/issues/draft-issue-form.tsx @@ -21,7 +21,7 @@ import { CycleDropdown, DateDropdown, EstimateDropdown, - ModuleDropdown, + ModuleSelectDropdown, PriorityDropdown, ProjectDropdown, ProjectMemberDropdown, @@ -152,7 +152,7 @@ export const DraftIssueForm: FC = observer((props) => { project_id: watch("project_id"), parent_id: watch("parent_id"), cycle_id: watch("cycle_id"), - module_id: watch("module_id"), + module_ids: watch("module_ids"), }; useEffect(() => { @@ -570,15 +570,17 @@ export const DraftIssueForm: FC = observer((props) => { )} /> )} - {projectDetails?.module_view && ( + + {projectDetails?.module_view && workspaceSlug && ( (
- onChange(moduleId)} buttonVariant="border-with-text" /> @@ -586,6 +588,7 @@ export const DraftIssueForm: FC = observer((props) => { )} /> )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && areEstimatesEnabledForProject(projectId) && ( = observer( cycle: cycleId.toString(), })); } - if (moduleId && !prePopulateDataProps?.module_id) { + if (moduleId && !prePopulateDataProps?.module_ids) { setPreloadedData((prevData) => ({ ...(prevData ?? {}), ...prePopulateDataProps, @@ -123,7 +123,7 @@ export const CreateUpdateDraftIssueModal: React.FC = observer( cycle: cycleId.toString(), })); } - if (moduleId && !prePopulateDataProps?.module_id) { + if (moduleId && !prePopulateDataProps?.module_ids) { setPreloadedData((prevData) => ({ ...(prevData ?? {}), ...prePopulateDataProps, @@ -233,11 +233,11 @@ export const CreateUpdateDraftIssueModal: React.FC = observer( }); }; - const addIssueToModule = async (issueId: string, moduleId: string) => { + const addIssueToModule = async (issueId: string, moduleIds: string[]) => { if (!workspaceSlug || !activeProject) return; - await moduleService.addIssuesToModule(workspaceSlug as string, activeProject ?? "", moduleId as string, { - issues: [issueId], + await moduleService.addModulesToIssue(workspaceSlug as string, activeProject ?? "", issueId as string, { + modules: moduleIds, }); }; @@ -248,7 +248,7 @@ export const CreateUpdateDraftIssueModal: React.FC = observer( .createIssue(workspaceSlug.toString(), activeProject, payload) .then(async (res) => { if (payload.cycle_id && payload.cycle_id !== "") await addIssueToCycle(res.id, payload.cycle_id); - if (payload.module_id && payload.module_id !== "") await addIssueToModule(res.id, payload.module_id); + if (payload.module_ids && payload.module_ids.length > 0) await addIssueToModule(res.id, payload.module_ids); setToastAlert({ type: "success", diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/module.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/module.tsx index e092ab08b..c8089d233 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/module.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/module.tsx @@ -59,7 +59,7 @@ export const IssueModuleActivity: FC = observer((props) => rel="noopener noreferrer" className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline" > - {activity.new_value} + {activity.old_value} )} 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/empty-states/module.tsx b/web/components/issues/issue-layouts/empty-states/module.tsx index a5b6d7255..aa3a8dc19 100644 --- a/web/components/issues/issue-layouts/empty-states/module.tsx +++ b/web/components/issues/issue-layouts/empty-states/module.tsx @@ -46,11 +46,7 @@ export const ModuleEmptyState: React.FC = 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/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/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/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-modal/form.tsx b/web/components/issues/issue-modal/form.tsx index dbae7e2d5..f1f5d873f 100644 --- a/web/components/issues/issue-modal/form.tsx +++ b/web/components/issues/issue-modal/form.tsx @@ -20,7 +20,7 @@ import { CycleDropdown, DateDropdown, EstimateDropdown, - ModuleDropdown, + ModuleSelectDropdown, PriorityDropdown, ProjectDropdown, ProjectMemberDropdown, @@ -44,7 +44,7 @@ const defaultValues: Partial = { 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/lib/app-provider.tsx b/web/lib/app-provider.tsx index dad6253c9..027800cd8 100644 --- a/web/lib/app-provider.tsx +++ b/web/lib/app-provider.tsx @@ -47,7 +47,7 @@ export const AppProvider: FC = observer((props) => { - = observer((props) => { posthogHost={envConfig?.posthog_host || null} > {children} - + */} + {children} diff --git a/web/services/module.service.ts b/web/services/module.service.ts index 4638f6ab2..ebddfb055 100644 --- a/web/services/module.service.ts +++ b/web/services/module.service.ts @@ -1,7 +1,7 @@ // services import { APIService } from "services/api.service"; // types -import type { IModule, TIssue, ILinkDetails, ModuleLink, TIssueMap } from "@plane/types"; +import type { IModule, TIssue, ILinkDetails, ModuleLink } from "@plane/types"; import { API_BASE_URL } from "helpers/common.helper"; export class ModuleService extends APIService { @@ -63,22 +63,7 @@ export class ModuleService extends APIService { } async getModuleIssues(workspaceSlug: string, projectId: string, moduleId: string, queries?: any): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/`, { - params: queries, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async getModuleIssuesWithParams( - workspaceSlug: string, - projectId: string, - moduleId: string, - queries?: any - ): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/`, { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/`, { params: queries, }) .then((response) => response?.data) @@ -92,15 +77,21 @@ export class ModuleService extends APIService { projectId: string, moduleId: string, data: { issues: string[] } - ): Promise< - { - issue: string; - issue_detail: TIssue; - module: string; - module_detail: IModule; - }[] - > { - return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/`, data) + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async addModulesToIssue( + workspaceSlug: string, + projectId: string, + issueId: string, + data: { modules: string[] } + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/modules/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; @@ -111,17 +102,53 @@ export class ModuleService extends APIService { workspaceSlug: string, projectId: string, moduleId: string, - bridgeId: string + issueId: string ): Promise { - return this.delete( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/${bridgeId}/` - ) + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } + async removeIssuesFromModuleBulk( + workspaceSlug: string, + projectId: string, + moduleId: string, + issueIds: string[] + ): Promise { + const promiseDataUrls: any = []; + issueIds.forEach((issueId) => { + promiseDataUrls.push( + this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`) + ); + }); + return await Promise.all(promiseDataUrls) + .then((response) => response) + .catch((error) => { + throw error?.response?.data; + }); + } + + async removeModulesFromIssueBulk( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleIds: string[] + ): Promise { + const promiseDataUrls: any = []; + moduleIds.forEach((moduleId) => { + promiseDataUrls.push( + this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`) + ); + }); + return await Promise.all(promiseDataUrls) + .then((response) => response) + .catch((error) => { + throw error?.response?.data; + }); + } + async createModuleLink( workspaceSlug: string, projectId: string, diff --git a/web/store/issue/issue-details/issue.store.ts b/web/store/issue/issue-details/issue.store.ts index be687eab8..46605c771 100644 --- a/web/store/issue/issue-details/issue.store.ts +++ b/web/store/issue/issue-details/issue.store.ts @@ -12,13 +12,14 @@ export interface IIssueStoreActions { removeIssue: (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: ( + addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise; + removeModulesFromIssue: ( workspaceSlug: string, projectId: string, - moduleId: string, - issueId: string - ) => Promise; + issueId: string, + moduleIds: string[] + ) => Promise; + removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise; } export interface IIssueStore extends IIssueStoreActions { @@ -143,15 +144,26 @@ export class IssueStore implements IIssueStore { return cycle; }; - addIssueToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { - const _module = await this.rootIssueDetailStore.rootIssueStore.moduleIssues.addIssueToModule( + addModulesToIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { + const _module = await this.rootIssueDetailStore.rootIssueStore.moduleIssues.addModulesToIssue( workspaceSlug, projectId, - moduleId, - issueIds + issueId, + moduleIds ); - if (issueIds && issueIds.length > 0) - await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueIds[0]); + if (moduleIds && moduleIds.length > 0) + await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId); + return _module; + }; + + removeModulesFromIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { + const _module = await this.rootIssueDetailStore.rootIssueStore.moduleIssues.removeModulesFromIssue( + workspaceSlug, + projectId, + issueId, + moduleIds + ); + await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId); return _module; }; diff --git a/web/store/issue/issue-details/root.store.ts b/web/store/issue/issue-details/root.store.ts index 9feb728c7..d78add446 100644 --- a/web/store/issue/issue-details/root.store.ts +++ b/web/store/issue/issue-details/root.store.ts @@ -143,8 +143,10 @@ export class IssueDetail implements IIssueDetail { this.issue.addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => this.issue.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); - addIssueToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => - this.issue.addIssueToModule(workspaceSlug, projectId, moduleId, issueIds); + addModulesToIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => + this.issue.addModulesToIssue(workspaceSlug, projectId, issueId, moduleIds); + removeModulesFromIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => + this.issue.removeModulesFromIssue(workspaceSlug, projectId, issueId, moduleIds); removeIssueFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => this.issue.removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId); diff --git a/web/store/issue/module/issue.store.ts b/web/store/issue/module/issue.store.ts index e24f03fb6..da2b127c1 100644 --- a/web/store/issue/module/issue.store.ts +++ b/web/store/issue/module/issue.store.ts @@ -52,13 +52,21 @@ export interface IModuleIssues { data: TIssue, moduleId?: string | undefined ) => Promise; - addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise; - removeIssueFromModule: ( + addIssuesToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise; + removeIssuesFromModule: ( workspaceSlug: string, projectId: string, moduleId: string, - issueId: string - ) => Promise; + issueIds: string[] + ) => Promise; + addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise; + removeModulesFromIssue: ( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleIds: string[] + ) => Promise; + removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise; } export class ModuleIssues extends IssueHelperStore implements IModuleIssues { @@ -90,7 +98,10 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { updateIssue: action, removeIssue: action, quickAddIssue: action, - addIssueToModule: action, + addIssuesToModule: action, + removeIssuesFromModule: action, + addModulesToIssue: action, + removeModulesFromIssue: action, removeIssueFromModule: action, }); @@ -175,7 +186,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { if (!moduleId) throw new Error("Module Id is required"); const response = await this.rootIssueStore.projectIssues.createIssue(workspaceSlug, projectId, data); - await this.addIssueToModule(workspaceSlug, projectId, moduleId, [response.id]); + await this.addIssuesToModule(workspaceSlug, projectId, moduleId, [response.id]); return response; } catch (error) { @@ -253,7 +264,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { } }; - addIssueToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { + addIssuesToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { try { const issueToModule = await this.moduleService.addIssuesToModule(workspaceSlug, projectId, moduleId, { issues: issueIds, @@ -261,11 +272,16 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { runInAction(() => { update(this.issues, moduleId, (moduleIssueIds = []) => { - uniq(concat(moduleIssueIds, issueIds)); + if (!moduleIssueIds) return [...issueIds]; + else return uniq(concat(moduleIssueIds, issueIds)); }); }); + issueIds.forEach((issueId) => { - this.rootStore.issues.updateIssue(issueId, { module_id: moduleId }); + update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => { + if (issueModuleIds.includes(moduleId)) return issueModuleIds; + else return uniq(concat(issueModuleIds, [moduleId])); + }); }); return issueToModule; @@ -274,14 +290,96 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { } }; + removeIssuesFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { + try { + runInAction(() => { + issueIds.forEach((issueId) => { + pull(this.issues[moduleId], issueId); + }); + }); + + runInAction(() => { + issueIds.forEach((issueId) => { + update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => { + if (issueModuleIds.includes(moduleId)) return pull(issueModuleIds, moduleId); + else return uniq(concat(issueModuleIds, [moduleId])); + }); + }); + }); + + const response = await this.moduleService.removeIssuesFromModuleBulk( + workspaceSlug, + projectId, + moduleId, + issueIds + ); + + return response; + } catch (error) { + throw error; + } + }; + + addModulesToIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { + try { + const issueToModule = await this.moduleService.addModulesToIssue(workspaceSlug, projectId, issueId, { + modules: moduleIds, + }); + + runInAction(() => { + moduleIds.forEach((moduleId) => { + update(this.issues, moduleId, (moduleIssueIds = []) => { + if (moduleIssueIds.includes(issueId)) return moduleIssueIds; + else return uniq(concat(moduleIssueIds, [issueId])); + }); + }); + update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => + uniq(concat(issueModuleIds, moduleIds)) + ); + }); + + return issueToModule; + } catch (error) { + throw error; + } + }; + + removeModulesFromIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { + try { + runInAction(() => { + moduleIds.forEach((moduleId) => { + update(this.issues, moduleId, (moduleIssueIds = []) => { + if (moduleIssueIds.includes(issueId)) return moduleIssueIds; + else return uniq(concat(moduleIssueIds, [issueId])); + }); + update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => + pull(issueModuleIds, moduleId) + ); + }); + }); + + const response = await this.moduleService.removeModulesFromIssueBulk( + workspaceSlug, + projectId, + issueId, + moduleIds + ); + + return response; + } catch (error) { + throw error; + } + }; + removeIssueFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => { try { runInAction(() => { pull(this.issues[moduleId], issueId); + update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => + pull(issueModuleIds, moduleId) + ); }); - this.rootStore.issues.updateIssue(issueId, { module_id: null }); - const response = await this.moduleService.removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId); return response;