diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 4cdcc7b768..be98bc312e 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 d81d32d3af..5e9f4f1230 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 1963a45e2b..1b1d9c37cb 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -600,16 +600,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 af476a1308..47fae2c9ca 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 3bacdae4cf..01eee78e39 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 5ea02e40e9..0b5c612d39 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 969adc2a5d..1f055129a9 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 0455541c68..13acabfe8c 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 07bf1ad038..27f31f7a9b 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 159fbcb08c..f4d3dbbb5e 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 4a036ec318..b9f6bd4110 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 0000000000..6238ef8257 --- /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 131af5e1cc..9af4e120e7 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 9734f85c2d..527abe6303 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 9c963258b1..42db704c67 100644 --- a/packages/types/src/projects.d.ts +++ b/packages/types/src/projects.d.ts @@ -128,7 +128,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/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index 7f1d49632f..61cc7081b2 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -64,8 +64,7 @@ export type TIssueParams = | "order_by" | "type" | "sub_issue" - | "show_empty_groups" - | "start_target_date"; + | "show_empty_groups"; export type TCalendarLayouts = "month" | "week"; @@ -93,7 +92,6 @@ export interface IIssueDisplayFilterOptions { layout?: TIssueLayouts; order_by?: TIssueOrderByOptions; show_empty_groups?: boolean; - start_target_date?: boolean; sub_issue?: boolean; type?: TIssueTypeFilters; } diff --git a/web/components/command-palette/command-palette.tsx b/web/components/command-palette/command-palette.tsx index 6a550e0ad7..213c35f8ee 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/activity.tsx b/web/components/core/activity.tsx index b5a666774a..72a67883ef 100644 --- a/web/components/core/activity.tsx +++ b/web/components/core/activity.tsx @@ -40,9 +40,9 @@ export const IssueLink = ({ activity }: { activity: IIssueActivity }) => { }`}`} target={activity.issue === null ? "_self" : "_blank"} rel={activity.issue === null ? "" : "noopener noreferrer"} - className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline whitespace-nowrap" + className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline" > - {`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}{" "} + {`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}{" "} {activity.issue_detail?.name} ) : ( @@ -267,7 +267,7 @@ const activityDetails: { {activity.new_value} {showIssue && ( - + {" "} to 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 0000000000..0e34eac2c4 --- /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/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx index d15317719b..96894e0ffd 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle-details.tsx @@ -164,7 +164,7 @@ export const ActiveCycleDetails: React.FC = observer((props

{truncateText(activeCycle.name, 70)}

- + {`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`} @@ -254,7 +254,7 @@ export const ActiveCycleDetails: React.FC = observer((props
Progress - +
{Object.keys(groupedIssues).map((group, index) => ( diff --git a/web/components/cycles/form.tsx b/web/components/cycles/form.tsx index e648a158ed..865cc68a1a 100644 --- a/web/components/cycles/form.tsx +++ b/web/components/cycles/form.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; // components import { DateDropdown, ProjectDropdown } from "components/dropdowns"; @@ -11,19 +12,28 @@ import { ICycle } from "@plane/types"; type Props = { handleFormSubmit: (values: Partial) => Promise; handleClose: () => void; + status: boolean; projectId: string; setActiveProject: (projectId: string) => void; data?: ICycle | null; }; +const defaultValues: Partial = { + name: "", + description: "", + start_date: null, + end_date: null, +}; + export const CycleForm: React.FC = (props) => { - const { handleFormSubmit, handleClose, projectId, setActiveProject, data } = props; + const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props; // form data const { formState: { errors, isSubmitting }, handleSubmit, control, watch, + reset, } = useForm({ defaultValues: { project: projectId, @@ -34,6 +44,13 @@ export const CycleForm: React.FC = (props) => { }, }); + useEffect(() => { + reset({ + ...defaultValues, + ...data, + }); + }, [data, reset]); + const startDate = watch("start_date"); const endDate = watch("end_date"); diff --git a/web/components/cycles/modal.tsx b/web/components/cycles/modal.tsx index fed6eefc24..8144feef7e 100644 --- a/web/components/cycles/modal.tsx +++ b/web/components/cycles/modal.tsx @@ -1,9 +1,9 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Dialog, Transition } from "@headlessui/react"; // services import { CycleService } from "services/cycle.service"; // hooks -import { useApplication, useCycle } from "hooks/store"; +import { useApplication, useCycle, useProject } from "hooks/store"; import useToast from "hooks/use-toast"; import useLocalStorage from "hooks/use-local-storage"; // components @@ -25,11 +25,12 @@ const cycleService = new CycleService(); export const CycleCreateUpdateModal: React.FC = (props) => { const { isOpen, handleClose, data, workspaceSlug, projectId } = props; // states - const [activeProject, setActiveProject] = useState(projectId); + const [activeProject, setActiveProject] = useState(null); // store hooks const { eventTracker: { postHogEventTracker }, } = useApplication(); + const { workspaceProjectIds } = useProject(); const { createCycle, updateCycleDetails } = useCycle(); // toast alert const { setToastAlert } = useToast(); @@ -134,6 +135,27 @@ export const CycleCreateUpdateModal: React.FC = (props) => { }); }; + useEffect(() => { + // if modal is closed, reset active project to null + // and return to avoid activeProject being set to some other project + if (!isOpen) { + setActiveProject(null); + return; + } + + // if data is present, set active project to the project of the + // issue. This has more priority than the project in the url. + if (data && data.project) { + setActiveProject(data.project); + return; + } + + // if data is not present, set active project to the project + // in the url. This has the least priority. + if (workspaceProjectIds && workspaceProjectIds.length > 0 && !activeProject) + setActiveProject(projectId ?? workspaceProjectIds?.[0] ?? null); + }, [activeProject, data, projectId, workspaceProjectIds, isOpen]); + return ( @@ -164,7 +186,8 @@ export const CycleCreateUpdateModal: React.FC = (props) => { diff --git a/web/components/dashboard/widgets/overview-stats.tsx b/web/components/dashboard/widgets/overview-stats.tsx index a4b9c678d7..418f0c63f4 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/dnd/StrictModeDroppable.tsx b/web/components/dnd/StrictModeDroppable.tsx deleted file mode 100644 index 9feba79b21..0000000000 --- a/web/components/dnd/StrictModeDroppable.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React, { useState, useEffect } from "react"; - -// react beautiful dnd -import { Droppable, DroppableProps } from "@hello-pangea/dnd"; - -const StrictModeDroppable = ({ children, ...props }: DroppableProps) => { - const [enabled, setEnabled] = useState(false); - - useEffect(() => { - const animation = requestAnimationFrame(() => setEnabled(true)); - - return () => { - cancelAnimationFrame(animation); - setEnabled(false); - }; - }, []); - - if (!enabled) return null; - - return {children}; -}; - -export default StrictModeDroppable; diff --git a/web/components/dropdowns/index.ts b/web/components/dropdowns/index.ts index 036ed9f757..53be7e4f5d 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 0000000000..85c97d4491 --- /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 0000000000..2161534fbe --- /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 0000000000..a8ddfccf7e --- /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 0000000000..b1c10eedb4 --- /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 0c368090d1..02d2eb8657 100644 --- a/web/components/gantt-chart/blocks/blocks-display.tsx +++ b/web/components/gantt-chart/blocks/blocks-display.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; // hooks import { useChart } from "../hooks"; // helpers -import { ChartDraggable } from "../helpers/draggable"; +import { ChartAddBlock, ChartDraggable } from "components/gantt-chart"; import { renderFormattedPayloadDate } from "helpers/date-time.helper"; // types import { IBlockUpdateData, IGanttBlock } from "../types"; @@ -15,6 +15,7 @@ export type GanttChartBlocksProps = { enableBlockLeftResize: boolean; enableBlockRightResize: boolean; enableBlockMove: boolean; + showAllBlocks: boolean; }; export const GanttChartBlocks: FC = (props) => { @@ -26,6 +27,7 @@ export const GanttChartBlocks: FC = (props) => { enableBlockLeftResize, enableBlockRightResize, enableBlockMove, + showAllBlocks, } = props; const { activeBlock, dispatch } = useChart(); @@ -45,6 +47,8 @@ export const GanttChartBlocks: FC = (props) => { totalBlockShifts: number, dragDirection: "left" | "right" | "move" ) => { + if (!block.start_date || !block.target_date) return; + const originalStartDate = new Date(block.start_date); const updatedStartDate = new Date(originalStartDate); @@ -75,27 +79,31 @@ export const GanttChartBlocks: FC = (props) => { > {blocks && blocks.length > 0 && - blocks.map( - (block) => - block.start_date && - block.target_date && ( -
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - > - handleChartBlockPosition(block, ...args)} - enableBlockLeftResize={enableBlockLeftResize} - enableBlockRightResize={enableBlockRightResize} - enableBlockMove={enableBlockMove} - /> -
- ) - )} + blocks.map((block) => { + // hide the block if it doesn't have start and target dates and showAllBlocks is false + if (!showAllBlocks && !(block.start_date && block.target_date)) return; + + const isBlockVisibleOnChart = block.start_date && block.target_date; + + return ( +
updateActiveBlock(block)} + onMouseLeave={() => updateActiveBlock(null)} + > + {!isBlockVisibleOnChart && } + handleChartBlockPosition(block, ...args)} + enableBlockLeftResize={enableBlockLeftResize} + enableBlockRightResize={enableBlockRightResize} + enableBlockMove={enableBlockMove} + /> +
+ ); + })}
); }; diff --git a/web/components/gantt-chart/chart/index.tsx b/web/components/gantt-chart/chart/index.tsx index 734d85efb2..4592bfb5b1 100644 --- a/web/components/gantt-chart/chart/index.tsx +++ b/web/components/gantt-chart/chart/index.tsx @@ -46,22 +46,25 @@ type ChartViewRootProps = { enableBlockMove: boolean; enableReorder: boolean; bottomSpacing: boolean; + showAllBlocks: boolean; }; -export const ChartViewRoot: FC = ({ - border, - title, - blocks = null, - loaderTitle, - blockUpdateHandler, - sidebarToRender, - blockToRender, - enableBlockLeftResize, - enableBlockRightResize, - enableBlockMove, - enableReorder, - bottomSpacing, -}) => { +export const ChartViewRoot: FC = (props) => { + const { + border, + title, + blocks = null, + loaderTitle, + blockUpdateHandler, + sidebarToRender, + blockToRender, + enableBlockLeftResize, + enableBlockRightResize, + enableBlockMove, + enableReorder, + bottomSpacing, + showAllBlocks, + } = props; // states const [itemsContainerWidth, setItemsContainerWidth] = useState(0); const [fullScreenMode, setFullScreenMode] = useState(false); @@ -311,6 +314,7 @@ export const ChartViewRoot: FC = ({ enableBlockLeftResize={enableBlockLeftResize} enableBlockRightResize={enableBlockRightResize} enableBlockMove={enableBlockMove} + showAllBlocks={showAllBlocks} /> )}
diff --git a/web/components/gantt-chart/chart/month.tsx b/web/components/gantt-chart/chart/month.tsx index 0bc6b7460f..0b7a4c452d 100644 --- a/web/components/gantt-chart/chart/month.tsx +++ b/web/components/gantt-chart/chart/month.tsx @@ -1,5 +1,4 @@ import { FC } from "react"; - // hooks import { useChart } from "../hooks"; // types diff --git a/web/components/gantt-chart/helpers/add-block.tsx b/web/components/gantt-chart/helpers/add-block.tsx new file mode 100644 index 0000000000..bfeddffa24 --- /dev/null +++ b/web/components/gantt-chart/helpers/add-block.tsx @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from "react"; +import { addDays } from "date-fns"; +import { Plus } from "lucide-react"; +// hooks +import { useChart } from "../hooks"; +// ui +import { Tooltip } from "@plane/ui"; +// helpers +import { renderFormattedDate, renderFormattedPayloadDate } from "helpers/date-time.helper"; +// types +import { IBlockUpdateData, IGanttBlock } from "../types"; + +type Props = { + block: IGanttBlock; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; +}; + +export const ChartAddBlock: React.FC = (props) => { + const { block, blockUpdateHandler } = props; + // states + const [isButtonVisible, setIsButtonVisible] = useState(false); + const [buttonXPosition, setButtonXPosition] = useState(0); + const [buttonStartDate, setButtonStartDate] = useState(null); + // refs + const containerRef = useRef(null); + // chart hook + const { currentViewData } = useChart(); + + const handleButtonClick = () => { + if (!currentViewData) return; + + const { startDate: chartStartDate, width } = currentViewData.data; + const columnNumber = buttonXPosition / width; + + const startDate = addDays(chartStartDate, columnNumber); + const endDate = addDays(startDate, 1); + + blockUpdateHandler(block.data, { + start_date: renderFormattedPayloadDate(startDate) ?? undefined, + target_date: renderFormattedPayloadDate(endDate) ?? undefined, + }); + }; + + useEffect(() => { + const container = containerRef.current; + + if (!container) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!currentViewData) return; + + setButtonXPosition(e.offsetX); + + const { startDate: chartStartDate, width } = currentViewData.data; + const columnNumber = buttonXPosition / width; + + const startDate = addDays(chartStartDate, columnNumber); + setButtonStartDate(startDate); + }; + + container.addEventListener("mousemove", handleMouseMove); + + return () => { + container?.removeEventListener("mousemove", handleMouseMove); + }; + }, [buttonXPosition, currentViewData]); + + return ( +
setIsButtonVisible(true)} + onMouseLeave={() => setIsButtonVisible(false)} + > +
+ {isButtonVisible && ( + + + + )} +
+ ); +}; diff --git a/web/components/gantt-chart/helpers/block-structure.tsx b/web/components/gantt-chart/helpers/block-structure.tsx index bc59624a5d..a7071ba282 100644 --- a/web/components/gantt-chart/helpers/block-structure.tsx +++ b/web/components/gantt-chart/helpers/block-structure.tsx @@ -3,14 +3,11 @@ import { TIssue } from "@plane/types"; import { IGanttBlock } from "components/gantt-chart"; export const renderIssueBlocksStructure = (blocks: TIssue[]): IGanttBlock[] => - blocks && blocks.length > 0 - ? blocks - .filter((b) => new Date(b?.start_date ?? "") <= new Date(b?.target_date ?? "")) - .map((block) => ({ - data: block, - id: block.id, - sort_order: block.sort_order, - start_date: new Date(block.start_date ?? ""), - target_date: new Date(block.target_date ?? ""), - })) - : []; + blocks && + blocks.map((block) => ({ + data: block, + id: block.id, + sort_order: block.sort_order, + start_date: block.start_date ? new Date(block.start_date) : null, + target_date: block.target_date ? new Date(block.target_date) : null, + })); diff --git a/web/components/gantt-chart/helpers/draggable.tsx b/web/components/gantt-chart/helpers/draggable.tsx index 8f4f23566d..d2c4448bbc 100644 --- a/web/components/gantt-chart/helpers/draggable.tsx +++ b/web/components/gantt-chart/helpers/draggable.tsx @@ -1,6 +1,4 @@ import React, { useEffect, useRef, useState } from "react"; - -// icons import { ArrowLeft, ArrowRight } from "lucide-react"; // hooks import { useChart } from "../hooks"; @@ -16,23 +14,17 @@ type Props = { enableBlockMove: boolean; }; -export const ChartDraggable: React.FC = ({ - block, - blockToRender, - handleBlock, - enableBlockLeftResize, - enableBlockRightResize, - enableBlockMove, -}) => { +export const ChartDraggable: React.FC = (props) => { + const { block, blockToRender, handleBlock, enableBlockLeftResize, enableBlockRightResize, enableBlockMove } = props; + // states const [isLeftResizing, setIsLeftResizing] = useState(false); const [isRightResizing, setIsRightResizing] = useState(false); const [isMoving, setIsMoving] = useState(false); const [posFromLeft, setPosFromLeft] = useState(null); - + // refs const resizableRef = useRef(null); - + // chart hook const { currentViewData, scrollLeft } = useChart(); - // check if cursor reaches either end while resizing/dragging const checkScrollEnd = (e: MouseEvent): number => { const SCROLL_THRESHOLD = 70; @@ -68,7 +60,6 @@ export const ChartDraggable: React.FC = ({ return delWidth; }; - // handle block resize from the left end const handleBlockLeftResize = (e: React.MouseEvent) => { if (!currentViewData || !resizableRef.current || !block.position) return; @@ -120,7 +111,6 @@ export const ChartDraggable: React.FC = ({ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }; - // handle block resize from the right end const handleBlockRightResize = (e: React.MouseEvent) => { if (!currentViewData || !resizableRef.current || !block.position) return; @@ -163,7 +153,6 @@ export const ChartDraggable: React.FC = ({ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }; - // handle block x-axis move const handleBlockMove = (e: React.MouseEvent) => { if (!enableBlockMove || !currentViewData || !resizableRef.current || !block.position) return; @@ -210,7 +199,6 @@ export const ChartDraggable: React.FC = ({ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }; - // scroll to a hidden block const handleScrollToBlock = () => { const scrollContainer = document.querySelector("#scroll-container") as HTMLElement; @@ -220,7 +208,6 @@ export const ChartDraggable: React.FC = ({ // update container's scroll position to the block's position scrollContainer.scrollLeft = block.position.marginLeft - 4; }; - // update block position from viewport's left end on scroll useEffect(() => { const block = resizableRef.current; @@ -229,7 +216,6 @@ export const ChartDraggable: React.FC = ({ setPosFromLeft(block.getBoundingClientRect().left); }, [scrollLeft]); - // check if block is hidden on either side const isBlockHiddenOnLeft = block.position?.marginLeft && diff --git a/web/components/gantt-chart/helpers/index.ts b/web/components/gantt-chart/helpers/index.ts index c4c919ec0f..1b51dc3747 100644 --- a/web/components/gantt-chart/helpers/index.ts +++ b/web/components/gantt-chart/helpers/index.ts @@ -1 +1,3 @@ +export * from "./add-block"; export * from "./block-structure"; +export * from "./draggable"; diff --git a/web/components/gantt-chart/root.tsx b/web/components/gantt-chart/root.tsx index 10c00a3632..7673da88e7 100644 --- a/web/components/gantt-chart/root.tsx +++ b/web/components/gantt-chart/root.tsx @@ -19,36 +19,43 @@ type GanttChartRootProps = { enableBlockMove?: boolean; enableReorder?: boolean; bottomSpacing?: boolean; + showAllBlocks?: boolean; }; -export const GanttChartRoot: FC = ({ - border = true, - title, - blocks, - loaderTitle = "blocks", - blockUpdateHandler, - sidebarToRender, - blockToRender, - enableBlockLeftResize = true, - enableBlockRightResize = true, - enableBlockMove = true, - enableReorder = true, - bottomSpacing = false, -}) => ( - - - -); +export const GanttChartRoot: FC = (props) => { + const { + border = true, + title, + blocks, + loaderTitle = "blocks", + blockUpdateHandler, + sidebarToRender, + blockToRender, + enableBlockLeftResize = true, + enableBlockRightResize = true, + enableBlockMove = true, + enableReorder = true, + bottomSpacing = false, + showAllBlocks = false, + } = props; + + return ( + + + + ); +}; diff --git a/web/components/gantt-chart/sidebar/cycle-sidebar.tsx b/web/components/gantt-chart/sidebar/cycle-sidebar.tsx index b7cb418375..1af1529c27 100644 --- a/web/components/gantt-chart/sidebar/cycle-sidebar.tsx +++ b/web/components/gantt-chart/sidebar/cycle-sidebar.tsx @@ -1,6 +1,5 @@ import { useRouter } from "next/router"; -import { DragDropContext, Draggable, DropResult } from "@hello-pangea/dnd"; -import StrictModeDroppable from "components/dnd/StrictModeDroppable"; +import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd"; import { MoreVertical } from "lucide-react"; // hooks import { useChart } from "components/gantt-chart/hooks"; @@ -83,7 +82,7 @@ export const CycleGanttSidebar: React.FC = (props) => { return ( - + {(droppableProvided) => (
= (props) => {
)} -
+
); }; diff --git a/web/components/gantt-chart/sidebar/module-sidebar.tsx b/web/components/gantt-chart/sidebar/module-sidebar.tsx index 8dc437269a..30f146dc55 100644 --- a/web/components/gantt-chart/sidebar/module-sidebar.tsx +++ b/web/components/gantt-chart/sidebar/module-sidebar.tsx @@ -1,6 +1,5 @@ import { useRouter } from "next/router"; -import { DragDropContext, Draggable, DropResult } from "@hello-pangea/dnd"; -import StrictModeDroppable from "components/dnd/StrictModeDroppable"; +import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; import { MoreVertical } from "lucide-react"; // hooks import { useChart } from "components/gantt-chart/hooks"; @@ -83,7 +82,7 @@ export const ModuleGanttSidebar: React.FC = (props) => { return ( - + {(droppableProvided) => (
= (props) => {
)} -
+
); }; diff --git a/web/components/gantt-chart/sidebar/project-view-sidebar.tsx b/web/components/gantt-chart/sidebar/project-view-sidebar.tsx index b591f3b733..da7382859f 100644 --- a/web/components/gantt-chart/sidebar/project-view-sidebar.tsx +++ b/web/components/gantt-chart/sidebar/project-view-sidebar.tsx @@ -1,6 +1,5 @@ import { useRouter } from "next/router"; -import { DragDropContext, Draggable, DropResult } from "@hello-pangea/dnd"; -import StrictModeDroppable from "components/dnd/StrictModeDroppable"; +import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; import { MoreVertical } from "lucide-react"; // hooks import { useChart } from "components/gantt-chart/hooks"; @@ -84,7 +83,7 @@ export const ProjectViewGanttSidebar: React.FC = (props) => { return ( - + {(droppableProvided) => (
= (props) => {
)} -
+
); }; diff --git a/web/components/gantt-chart/sidebar/sidebar.tsx b/web/components/gantt-chart/sidebar/sidebar.tsx index 88a138a1be..062b764514 100644 --- a/web/components/gantt-chart/sidebar/sidebar.tsx +++ b/web/components/gantt-chart/sidebar/sidebar.tsx @@ -1,6 +1,5 @@ import { useRouter } from "next/router"; -import { DragDropContext, Draggable, DropResult } from "@hello-pangea/dnd"; -import StrictModeDroppable from "components/dnd/StrictModeDroppable"; +import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; import { MoreVertical } from "lucide-react"; // hooks import { useChart } from "components/gantt-chart/hooks"; @@ -27,10 +26,10 @@ type Props = { ) => Promise; viewId?: string; disableIssueCreation?: boolean; + showAllBlocks?: boolean; }; export const IssueGanttSidebar: React.FC = (props) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { blockUpdateHandler, blocks, @@ -39,6 +38,7 @@ export const IssueGanttSidebar: React.FC = (props) => { quickAddCallback, viewId, disableIssueCreation, + showAllBlocks = false, } = props; const router = useRouter(); @@ -100,7 +100,7 @@ export const IssueGanttSidebar: React.FC = (props) => { return ( - + {(droppableProvided) => (
= (props) => { <> {blocks ? ( blocks.map((block, index) => { - const duration = findTotalDaysInRange(block.start_date ?? "", block.target_date ?? ""); + const isBlockVisibleOnSidebar = block.start_date && block.target_date; + + // hide the block if it doesn't have start and target dates and showAllBlocks is false + if (!showAllBlocks && !isBlockVisibleOnSidebar) return; + + const duration = + !block.start_date || !block.target_date + ? null + : findTotalDaysInRange(block.start_date, block.target_date); return ( = (props) => {
- {duration} day{duration > 1 ? "s" : ""} + {duration && ( + + {duration} day{duration > 1 ? "s" : ""} + + )}
@@ -173,7 +185,7 @@ export const IssueGanttSidebar: React.FC = (props) => { )}
)} - + ); }; diff --git a/web/components/gantt-chart/types/index.ts b/web/components/gantt-chart/types/index.ts index 9cab40f5cc..1360f9f45a 100644 --- a/web/components/gantt-chart/types/index.ts +++ b/web/components/gantt-chart/types/index.ts @@ -13,8 +13,8 @@ export interface IGanttBlock { width: number; }; sort_order: number; - start_date: Date; - target_date: Date; + start_date: Date | null; + target_date: Date | null; } export interface IBlockUpdateData { diff --git a/web/components/gantt-chart/views/month-view.ts b/web/components/gantt-chart/views/month-view.ts index fc145d69c3..13d054da1a 100644 --- a/web/components/gantt-chart/views/month-view.ts +++ b/web/components/gantt-chart/views/month-view.ts @@ -167,6 +167,8 @@ export const getMonthChartItemPositionWidthInMonth = (chartData: ChartDataType, const { startDate } = chartData.data; const { start_date: itemStartDate, target_date: itemTargetDate } = itemData; + if (!itemStartDate || !itemTargetDate) return null; + startDate.setHours(0, 0, 0, 0); itemStartDate.setHours(0, 0, 0, 0); itemTargetDate.setHours(0, 0, 0, 0); diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index 949c192fab..fc0075030e 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 (
+
- -
- } - 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/parent/siblings.tsx b/web/components/issues/issue-detail/parent/siblings.tsx index bc93ff1386..45eca81d45 100644 --- a/web/components/issues/issue-detail/parent/siblings.tsx +++ b/web/components/issues/issue-detail/parent/siblings.tsx @@ -1,6 +1,5 @@ import { FC } from "react"; import useSWR from "swr"; -import { observer } from "mobx-react-lite"; // components import { IssueParentSiblingItem } from "./sibling-item"; // hooks diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx index 58a52cc977..9a16dcbf04 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 0a38c30175..f2ee876b98 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/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx index 0f81d79a60..d486b2f487 100644 --- a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import { observer } from "mobx-react-lite"; // hooks -import { useProject, useWorkspace } from "hooks/store"; +import { useProject } from "hooks/store"; import useToast from "hooks/use-toast"; import useKeypress from "hooks/use-keypress"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; @@ -57,14 +57,13 @@ const Inputs = (props: any) => { }; export const CalendarQuickAddIssueForm: React.FC = observer((props) => { - const { formKey, groupId, prePopulatedData, quickAddCallback, viewId, onOpen } = props; + const { formKey, prePopulatedData, quickAddCallback, viewId, onOpen } = props; // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; // store hooks const { getProjectById } = useProject(); - const { getWorkspaceBySlug } = useWorkspace(); // refs const ref = useRef(null); // states @@ -73,7 +72,6 @@ export const CalendarQuickAddIssueForm: React.FC = observer((props) => { const { setToastAlert } = useToast(); // derived values - const workspaceDetail = (workspaceSlug && getWorkspaceBySlug(workspaceSlug.toString())) || null; const projectDetail = projectId ? getProjectById(projectId.toString()) : null; const { diff --git a/web/components/issues/issue-layouts/empty-states/archived-issues.tsx b/web/components/issues/issue-layouts/empty-states/archived-issues.tsx index f04144d049..89eb581104 100644 --- a/web/components/issues/issue-layouts/empty-states/archived-issues.tsx +++ b/web/components/issues/issue-layouts/empty-states/archived-issues.tsx @@ -45,7 +45,7 @@ export const ProjectArchivedEmptyState: React.FC = observer(() => { const issueFilterCount = size( Object.fromEntries( - Object.entries(userFilters ?? {}).filter(([key, value]) => value && Array.isArray(value) && value.length > 0) + Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0) ) ); diff --git a/web/components/issues/issue-layouts/empty-states/draft-issues.tsx b/web/components/issues/issue-layouts/empty-states/draft-issues.tsx index ba6a9ed2a1..1d2695ff99 100644 --- a/web/components/issues/issue-layouts/empty-states/draft-issues.tsx +++ b/web/components/issues/issue-layouts/empty-states/draft-issues.tsx @@ -45,7 +45,7 @@ export const ProjectDraftEmptyState: React.FC = observer(() => { const issueFilterCount = size( Object.fromEntries( - Object.entries(userFilters ?? {}).filter(([key, value]) => value && Array.isArray(value) && value.length > 0) + Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0) ) ); diff --git a/web/components/issues/issue-layouts/empty-states/module.tsx b/web/components/issues/issue-layouts/empty-states/module.tsx index a5b6d72554..aa3a8dc19a 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/empty-states/project-issues.tsx b/web/components/issues/issue-layouts/empty-states/project-issues.tsx index ee6216634c..b72dfff184 100644 --- a/web/components/issues/issue-layouts/empty-states/project-issues.tsx +++ b/web/components/issues/issue-layouts/empty-states/project-issues.tsx @@ -49,7 +49,7 @@ export const ProjectEmptyState: React.FC = observer(() => { const issueFilterCount = size( Object.fromEntries( - Object.entries(userFilters ?? {}).filter(([key, value]) => value && Array.isArray(value) && value.length > 0) + Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0) ) ); diff --git a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx index 73802886ea..601205b5c8 100644 --- a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx +++ b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx @@ -13,11 +13,12 @@ import { } from "components/gantt-chart"; // types import { TIssue, TUnGroupedIssues } from "@plane/types"; -import { EUserProjectRoles } from "constants/project"; import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle"; import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module"; import { IProjectIssues, IProjectIssuesFilter } from "store/issue/project"; import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; +// constants +import { EUserProjectRoles } from "constants/project"; import { EIssueActions } from "../types"; interface IBaseGanttRoot { @@ -76,12 +77,14 @@ export const BaseGanttRoot: React.FC = observer((props: IBaseGan viewId={viewId} enableQuickIssueCreate disableIssueCreation={!enableIssueCreation || !isAllowed} + showAllBlocks /> )} enableBlockLeftResize={isAllowed} enableBlockRightResize={isAllowed} enableBlockMove={isAllowed} enableReorder={appliedDisplayFilters?.order_by === "sort_order" && isAllowed} + showAllBlocks />
diff --git a/web/components/issues/issue-layouts/gantt/blocks.tsx b/web/components/issues/issue-layouts/gantt/blocks.tsx index fefee880dd..cf1d5d7009 100644 --- a/web/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/components/issues/issue-layouts/gantt/blocks.tsx @@ -1,4 +1,3 @@ -import { useRouter } from "next/router"; // ui import { Tooltip, StateGroupIcon, ControlLink } from "@plane/ui"; // helpers 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 5a94b6bacf..eb7005cbd9 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 05c2b5d451..713a6644a1 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/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx index 21aeb3d9d8..b4610a2e0e 100644 --- a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx @@ -54,7 +54,7 @@ const defaultValues: Partial = { }; export const KanBanQuickAddIssueForm: React.FC = observer((props) => { - const { formKey, groupId, prePopulatedData, quickAddCallback, viewId } = props; + const { formKey, prePopulatedData, quickAddCallback, viewId } = props; // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; 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 89f4683af8..c3af69e6eb 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 b718269b69..10f3582f14 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 cf56d6b5d5..7a7a2d1ab8 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 fb874b8f6c..520a2da32b 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/roots/all-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index be853b64a5..2ba0236741 100644 --- a/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -61,6 +61,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => { ["all-issues", "assigned", "created", "subscribed"].includes(globalViewId.toString()) ) { const routerQueryParams = { ...router.query }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { ["workspaceSlug"]: _workspaceSlug, ["globalViewId"]: _globalViewId, ...filters } = routerQueryParams; let issueFilters: any = {}; diff --git a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx index 3a022d4475..a5667f99d7 100644 --- a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx @@ -2,7 +2,7 @@ import { FC, useCallback } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks -import { useIssues, useUser } from "hooks/store"; +import { useUser } from "hooks/store"; // views import { SpreadsheetView } from "./spreadsheet-view"; // types @@ -36,7 +36,6 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; // store hooks - const { issueMap } = useIssues(); const { membership: { currentProjectRole }, } = useUser(); @@ -55,7 +54,6 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { [canEditPropertiesBasedOnProject, enableInlineEditing, isEditingAllowed] ); - const issueIds = (issueStore.groupedIssueIds ?? []) as TUnGroupedIssues; const handleIssues = useCallback( diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx index dbae7e2d54..f1f5d873f6 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 c3c38c572e..da13e63537 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 6aee23a23d..ea00b845aa 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 89a659fb3b..5041e5d2a8 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/select/label.tsx b/web/components/issues/select/label.tsx index 00f095d9e4..54931f85d3 100644 --- a/web/components/issues/select/label.tsx +++ b/web/components/issues/select/label.tsx @@ -1,6 +1,5 @@ import React, { Fragment, useRef, useState } from "react"; import { useRouter } from "next/router"; -import useSWR from "swr"; import { Combobox } from "@headlessui/react"; import { usePopper } from "react-popper"; import { observer } from "mobx-react-lite"; diff --git a/web/components/pages/create-update-page-modal.tsx b/web/components/pages/create-update-page-modal.tsx index e6763acc63..b13f68fa43 100644 --- a/web/components/pages/create-update-page-modal.tsx +++ b/web/components/pages/create-update-page-modal.tsx @@ -1,8 +1,6 @@ import React, { FC } from "react"; import { useRouter } from "next/router"; import { Dialog, Transition } from "@headlessui/react"; -// hooks -import { useApplication } from "hooks/store"; // components import { PageForm } from "./page-form"; // types @@ -25,10 +23,6 @@ export const CreateUpdatePageModal: FC = (props) => { const { workspaceSlug } = router.query; const { createPage } = useProjectPages(); - // store hooks - const { - eventTracker: { postHogEventTracker }, - } = useApplication(); const createProjectPage = async (payload: IPage) => { if (!workspaceSlug) return; diff --git a/web/components/project/sidebar-list-item.tsx b/web/components/project/sidebar-list-item.tsx index 1a51538846..8e4188d811 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) => { >