diff --git a/admin/package.json b/admin/package.json index c8b072626..527ae9288 100644 --- a/admin/package.json +++ b/admin/package.json @@ -14,6 +14,7 @@ "@headlessui/react": "^1.7.19", "@plane/types": "*", "@plane/ui": "*", + "@plane/constants": "*", "@tailwindcss/typography": "^0.5.9", "@types/lodash": "^4.17.0", "autoprefixer": "10.4.14", @@ -46,4 +47,4 @@ "tsconfig": "*", "typescript": "^5.4.2" } -} +} \ No newline at end of file diff --git a/apiserver/plane/app/serializers/view.py b/apiserver/plane/app/serializers/view.py index 6b08ae80b..b8376a047 100644 --- a/apiserver/plane/app/serializers/view.py +++ b/apiserver/plane/app/serializers/view.py @@ -23,7 +23,7 @@ class IssueViewSerializer(DynamicBaseSerializer): ] def create(self, validated_data): - query_params = validated_data.get("query_data", {}) + query_params = validated_data.get("filters", {}) if bool(query_params): validated_data["query"] = issue_filters(query_params, "POST") else: @@ -31,7 +31,7 @@ class IssueViewSerializer(DynamicBaseSerializer): return IssueView.objects.create(**validated_data) def update(self, instance, validated_data): - query_params = validated_data.get("query_data", {}) + query_params = validated_data.get("filters", {}) if bool(query_params): validated_data["query"] = issue_filters(query_params, "POST") else: diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 67cda14af..2169f1e1d 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -1391,6 +1391,7 @@ def create_issue_relation_activity( workspace_id=workspace_id, comment=f"added {requested_data.get('relation_type')} relation", old_identifier=related_issue, + epoch=epoch, ) ) issue = Issue.objects.get(pk=issue_id) diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py index 3822b531e..022c536de 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -6,6 +6,7 @@ from django.db import models from .base import BaseModel from .project import ProjectBaseModel from .workspace import WorkspaceBaseModel +from plane.utils.issue_filters import issue_filters def get_default_filters(): @@ -116,6 +117,26 @@ class IssueView(WorkspaceBaseModel): db_table = "issue_views" ordering = ("-created_at",) + def save(self, *args, **kwargs): + query_params = self.filters + self.query = ( + issue_filters(query_params, "POST") if query_params else {} + ) + + if self._state.adding: + if self.project: + largest_sort_order = IssueView.objects.filter( + project=self.project + ).aggregate(largest=models.Max("sort_order"))["largest"] + else: + largest_sort_order = IssueView.objects.filter( + workspace=self.workspace, project__isnull=True + ).aggregate(largest=models.Max("sort_order"))["largest"] + if largest_sort_order is not None: + self.sort_order = largest_sort_order + 10000 + + super(IssueView, self).save(*args, **kwargs) + def __str__(self): """Return name of the View""" return f"{self.name} <{self.project.name}>" diff --git a/apiserver/plane/space/serializer/__init__.py b/apiserver/plane/space/serializer/__init__.py index cd10fb5c6..63dfe9262 100644 --- a/apiserver/plane/space/serializer/__init__.py +++ b/apiserver/plane/space/serializer/__init__.py @@ -1,5 +1,9 @@ from .user import UserLiteSerializer -from .issue import LabelLiteSerializer, StateLiteSerializer +from .issue import ( + LabelLiteSerializer, + StateLiteSerializer, + IssuePublicSerializer, +) from .state import StateSerializer, StateLiteSerializer diff --git a/apiserver/plane/space/serializer/issue.py b/apiserver/plane/space/serializer/issue.py index c7b044b21..401e7d719 100644 --- a/apiserver/plane/space/serializer/issue.py +++ b/apiserver/plane/space/serializer/issue.py @@ -188,11 +188,16 @@ class IssueAttachmentSerializer(BaseSerializer): class IssueReactionSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") class Meta: model = IssueReaction - fields = "__all__" + fields = [ + "issue", + "reaction", + "workspace", + "project", + "actor", + ] read_only_fields = [ "workspace", "project", @@ -454,20 +459,6 @@ class IssueCreateSerializer(BaseSerializer): return super().update(instance, validated_data) -class IssueReactionSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") - - class Meta: - model = IssueReaction - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "issue", - "actor", - ] - - class CommentReactionSerializer(BaseSerializer): class Meta: model = CommentReaction @@ -476,7 +467,6 @@ class CommentReactionSerializer(BaseSerializer): class IssueVoteSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") class Meta: model = IssueVote @@ -486,35 +476,45 @@ class IssueVoteSerializer(BaseSerializer): "workspace", "project", "actor", - "actor_detail", ] read_only_fields = fields class IssuePublicSerializer(BaseSerializer): - project_detail = ProjectLiteSerializer(read_only=True, source="project") - state_detail = StateLiteSerializer(read_only=True, source="state") reactions = IssueReactionSerializer( read_only=True, many=True, source="issue_reactions" ) votes = IssueVoteSerializer(read_only=True, many=True) + module_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + ) + label_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + ) + assignee_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + ) class Meta: model = Issue fields = [ "id", "name", - "description_html", "sequence_id", "state", - "state_detail", "project", - "project_detail", "workspace", "priority", "target_date", "reactions", "votes", + "module_ids", + "created_by", + "label_ids", + "assignee_ids", ] read_only_fields = fields diff --git a/apiserver/plane/space/urls/project.py b/apiserver/plane/space/urls/project.py index 3294b01f6..7676c9599 100644 --- a/apiserver/plane/space/urls/project.py +++ b/apiserver/plane/space/urls/project.py @@ -5,6 +5,11 @@ from plane.space.views import ( ProjectDeployBoardPublicSettingsEndpoint, ProjectIssuesPublicEndpoint, WorkspaceProjectAnchorEndpoint, + ProjectCyclesEndpoint, + ProjectModulesEndpoint, + ProjectStatesEndpoint, + ProjectLabelsEndpoint, + ProjectMembersEndpoint, ) urlpatterns = [ @@ -23,4 +28,29 @@ urlpatterns = [ WorkspaceProjectAnchorEndpoint.as_view(), name="project-deploy-board", ), + path( + "anchor//cycles/", + ProjectCyclesEndpoint.as_view(), + name="project-cycles", + ), + path( + "anchor//modules/", + ProjectModulesEndpoint.as_view(), + name="project-modules", + ), + path( + "anchor//states/", + ProjectStatesEndpoint.as_view(), + name="project-states", + ), + path( + "anchor//labels/", + ProjectLabelsEndpoint.as_view(), + name="project-labels", + ), + path( + "anchor//members/", + ProjectMembersEndpoint.as_view(), + name="project-members", + ), ] diff --git a/apiserver/plane/space/utils/grouper.py b/apiserver/plane/space/utils/grouper.py new file mode 100644 index 000000000..9a3cde7ad --- /dev/null +++ b/apiserver/plane/space/utils/grouper.py @@ -0,0 +1,248 @@ +# Django imports +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Q, UUIDField, Value, F, Case, When, JSONField +from django.db.models.functions import Coalesce, JSONObject + +# Module imports +from plane.db.models import ( + Cycle, + Issue, + Label, + Module, + Project, + ProjectMember, + State, + WorkspaceMember, +) + +def issue_queryset_grouper(queryset, group_by, sub_group_by): + + FIELD_MAPPER = { + "label_ids": "labels__id", + "assignee_ids": "assignees__id", + "module_ids": "issue_module__module_id", + } + + annotations_map = { + "assignee_ids": ("assignees__id", ~Q(assignees__id__isnull=True)), + "label_ids": ("labels__id", ~Q(labels__id__isnull=True)), + "module_ids": ( + "issue_module__module_id", + ~Q(issue_module__module_id__isnull=True), + ), + } + default_annotations = { + key: Coalesce( + ArrayAgg( + field, + distinct=True, + filter=condition, + ), + Value([], output_field=ArrayField(UUIDField())), + ) + for key, (field, condition) in annotations_map.items() + if FIELD_MAPPER.get(key) != group_by + or FIELD_MAPPER.get(key) != sub_group_by + } + + return queryset.annotate(**default_annotations) + + +def issue_on_results(issues, group_by, sub_group_by): + + FIELD_MAPPER = { + "labels__id": "label_ids", + "assignees__id": "assignee_ids", + "issue_module__module_id": "module_ids", + } + + original_list = ["assignee_ids", "label_ids", "module_ids"] + + required_fields = [ + "id", + "name", + "state_id", + "sort_order", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "created_by", + "state__group", + ] + + if group_by in FIELD_MAPPER: + original_list.remove(FIELD_MAPPER[group_by]) + original_list.append(group_by) + + if sub_group_by in FIELD_MAPPER: + original_list.remove(FIELD_MAPPER[sub_group_by]) + original_list.append(sub_group_by) + + required_fields.extend(original_list) + + issues = issues.annotate( + vote_items=ArrayAgg( + Case( + When( + votes__isnull=False, + then=JSONObject( + vote=F("votes__vote"), + actor_details=JSONObject( + id=F("votes__actor__id"), + first_name=F("votes__actor__first_name"), + last_name=F("votes__actor__last_name"), + avatar=F("votes__actor__avatar"), + display_name=F("votes__actor__display_name"), + ) + ), + ), + default=None, + output_field=JSONField(), + ), + filter=Case( + When(votes__isnull=False, then=True), + default=False, + output_field=JSONField(), + ), + distinct=True, + ), + reaction_items=ArrayAgg( + Case( + When( + issue_reactions__isnull=False, + then=JSONObject( + reaction=F("issue_reactions__reaction"), + actor_details=JSONObject( + id=F("issue_reactions__actor__id"), + first_name=F("issue_reactions__actor__first_name"), + last_name=F("issue_reactions__actor__last_name"), + avatar=F("issue_reactions__actor__avatar"), + display_name=F("issue_reactions__actor__display_name"), + ), + ), + ), + default=None, + output_field=JSONField(), + ), + filter=Case( + When(issue_reactions__isnull=False, then=True), + default=False, + output_field=JSONField(), + ), + distinct=True, + ), + ).values(*required_fields, "vote_items", "reaction_items") + + return issues + + +def issue_group_values(field, slug, project_id=None, filters=dict): + if field == "state_id": + queryset = State.objects.filter( + is_triage=False, + workspace__slug=slug, + ).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + if field == "labels__id": + queryset = Label.objects.filter(workspace__slug=slug).values_list( + "id", flat=True + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + else: + return list(queryset) + ["None"] + if field == "assignees__id": + if project_id: + return ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + is_active=True, + ).values_list("member_id", flat=True) + else: + return list( + WorkspaceMember.objects.filter( + workspace__slug=slug, is_active=True + ).values_list("member_id", flat=True) + ) + if field == "issue_module__module_id": + queryset = Module.objects.filter( + workspace__slug=slug, + ).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + else: + return list(queryset) + ["None"] + if field == "cycle_id": + queryset = Cycle.objects.filter( + workspace__slug=slug, + ).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + else: + return list(queryset) + ["None"] + if field == "project_id": + queryset = Project.objects.filter(workspace__slug=slug).values_list( + "id", flat=True + ) + return list(queryset) + if field == "priority": + return [ + "low", + "medium", + "high", + "urgent", + "none", + ] + if field == "state__group": + return [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + if field == "target_date": + queryset = ( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(**filters) + .values_list("target_date", flat=True) + .distinct() + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + if field == "start_date": + queryset = ( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(**filters) + .values_list("start_date", flat=True) + .distinct() + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + + if field == "created_by": + queryset = ( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(**filters) + .values_list("created_by", flat=True) + .distinct() + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + + return [] diff --git a/apiserver/plane/space/views/__init__.py b/apiserver/plane/space/views/__init__.py index eced7d1b4..f5e860d87 100644 --- a/apiserver/plane/space/views/__init__.py +++ b/apiserver/plane/space/views/__init__.py @@ -2,6 +2,7 @@ from .project import ( ProjectDeployBoardPublicSettingsEndpoint, WorkspaceProjectDeployBoardEndpoint, WorkspaceProjectAnchorEndpoint, + ProjectMembersEndpoint, ) from .issue import ( @@ -14,3 +15,11 @@ from .issue import ( ) from .inbox import InboxIssuePublicViewSet + +from .cycle import ProjectCyclesEndpoint + +from .module import ProjectModulesEndpoint + +from .state import ProjectStatesEndpoint + +from .label import ProjectLabelsEndpoint diff --git a/apiserver/plane/space/views/cycle.py b/apiserver/plane/space/views/cycle.py new file mode 100644 index 000000000..49cc82e89 --- /dev/null +++ b/apiserver/plane/space/views/cycle.py @@ -0,0 +1,35 @@ +# Third Party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.db.models import ( + DeployBoard, + Cycle, +) + + +class ProjectCyclesEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, anchor): + deploy_board = DeployBoard.objects.filter(anchor=anchor).first() + if not deploy_board: + return Response( + {"error": "Invalid anchor"}, + status=status.HTTP_404_NOT_FOUND, + ) + + cycles = Cycle.objects.filter( + workspace__slug=deploy_board.workspace.slug, + project_id=deploy_board.project_id, + ).values("id", "name") + + return Response( + cycles, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index 6ece02cbb..efda1c0b9 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -1,20 +1,19 @@ # Python imports import json - +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models.functions import Coalesce, JSONObject from django.core.serializers.json import DjangoJSONEncoder from django.db.models import ( Exists, F, - Func, - OuterRef, Q, Prefetch, + UUIDField, Case, When, - CharField, - IntegerField, + JSONField, Value, - Max, ) # Django imports @@ -25,6 +24,22 @@ from rest_framework.permissions import AllowAny, IsAuthenticated # Third Party imports from rest_framework.response import Response +# Module imports +from .base import BaseAPIView, BaseViewSet + +# fetch the space app grouper function separately +from plane.space.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) + + +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) from plane.app.serializers import ( CommentReactionSerializer, IssueCommentSerializer, @@ -36,21 +51,160 @@ from plane.db.models import ( Issue, IssueComment, IssueLink, - IssueAttachment, - ProjectMember, IssueReaction, + ProjectMember, CommentReaction, DeployBoard, IssueVote, ProjectPublicMember, - State, - Label, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters -# Module imports -from .base import BaseAPIView, BaseViewSet + +class ProjectIssuesPublicEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, anchor): + filters = issue_filters(request.query_params, "GET") + order_by_param = request.GET.get("order_by", "-created_at") + + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + if not deploy_board: + return Response( + {"error": "Project is not published"}, + status=status.HTTP_404_NOT_FOUND, + ) + + project_id = deploy_board.entity_identifier + slug = deploy_board.workspace.slug + + issue_queryset = ( + Issue.issue_objects.filter( + workspace__slug=slug, project_id=project_id + ) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + .prefetch_related( + Prefetch( + "votes", + queryset=IssueVote.objects.select_related("actor"), + ) + ) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + ).distinct() + + issue_queryset = issue_queryset.filter(**filters) + + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) + + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + else: + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + ) class IssueCommentPublicViewSet(BaseViewSet): @@ -503,67 +657,50 @@ class IssueRetrievePublicEndpoint(BaseAPIView): ] def get(self, request, anchor, issue_id): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) - issue = Issue.objects.get( - workspace_id=project_deploy_board.workspace_id, - project_id=project_deploy_board.project_id, - pk=issue_id, - ) - serializer = IssuePublicSerializer(issue) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class ProjectIssuesPublicEndpoint(BaseAPIView): - permission_classes = [ - AllowAny, - ] - - def get(self, request, anchor): - deploy_board = DeployBoard.objects.filter( - anchor=anchor, entity_name="project" - ).first() - if not deploy_board: - return Response( - {"error": "Project is not published"}, - status=status.HTTP_404_NOT_FOUND, - ) - - filters = issue_filters(request.query_params, "GET") - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - - order_by_param = request.GET.get("order_by", "-created_at") - - project_id = deploy_board.entity_identifier - slug = deploy_board.workspace.slug + deploy_board = DeployBoard.objects.get(anchor=anchor) issue_queryset = ( - Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") + Issue.issue_objects.filter( + pk=issue_id, + workspace__slug=deploy_board.workspace.slug, + project_id=deploy_board.project_id, + ) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), ) - .filter(project_id=project_id) - .filter(workspace__slug=slug) - .select_related("project", "workspace", "state", "parent") - .prefetch_related("assignees", "labels") .prefetch_related( Prefetch( "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), + queryset=IssueReaction.objects.select_related( + "issue", "actor" + ), ) ) .prefetch_related( @@ -572,124 +709,91 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): queryset=IssueVote.objects.select_related("actor"), ) ) - .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 - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issues = IssuePublicSerializer(issue_queryset, many=True).data - - state_group_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - - states = ( - State.objects.filter( - ~Q(name="Triage"), - workspace__slug=slug, - project_id=project_id, - ) - .annotate( - custom_order=Case( - *[ - When(group=value, then=Value(index)) - for index, value in enumerate(state_group_order) - ], - default=Value(len(state_group_order)), - output_field=IntegerField(), + vote_items=ArrayAgg( + Case( + When( + votes__isnull=False, + then=JSONObject( + vote=F("votes__vote"), + actor_details=JSONObject( + id=F("votes__actor__id"), + first_name=F("votes__actor__first_name"), + last_name=F("votes__actor__last_name"), + avatar=F("votes__actor__avatar"), + display_name=F( + "votes__actor__display_name" + ), + ), + ), + ), + default=None, + output_field=JSONField(), + ), + filter=Case( + When(votes__isnull=False, then=True), + default=False, + output_field=JSONField(), + ), + distinct=True, + ), + reaction_items=ArrayAgg( + Case( + When( + issue_reactions__isnull=False, + then=JSONObject( + reaction=F("issue_reactions__reaction"), + actor_details=JSONObject( + id=F("issue_reactions__actor__id"), + first_name=F( + "issue_reactions__actor__first_name" + ), + last_name=F( + "issue_reactions__actor__last_name" + ), + avatar=F("issue_reactions__actor__avatar"), + display_name=F( + "issue_reactions__actor__display_name" + ), + ), + ), + ), + default=None, + output_field=JSONField(), + ), + filter=Case( + When(issue_reactions__isnull=False, then=True), + default=False, + output_field=JSONField(), + ), + distinct=True, ), ) - .values("name", "group", "color", "id") - .order_by("custom_order", "sequence") - ) + .values( + "id", + "name", + "state_id", + "sort_order", + "description", + "description_html", + "description_stripped", + "description_binary", + "module_ids", + "label_ids", + "assignee_ids", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "created_by", + "state__group", + "vote_items", + "reaction_items", + ) + ).first() - labels = Label.objects.filter( - workspace__slug=slug, project_id=project_id - ).values("id", "name", "color", "parent") - - return Response( - { - "issues": issues, - "states": states, - "labels": labels, - }, - status=status.HTTP_200_OK, - ) + return Response(issue_queryset, status=status.HTTP_200_OK) diff --git a/apiserver/plane/space/views/label.py b/apiserver/plane/space/views/label.py new file mode 100644 index 000000000..2e0f99b7c --- /dev/null +++ b/apiserver/plane/space/views/label.py @@ -0,0 +1,35 @@ +# Third Party imports +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny + +# Module imports +from .base import BaseAPIView +from plane.db.models import ( + DeployBoard, + Label, +) + + +class ProjectLabelsEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, anchor): + deploy_board = DeployBoard.objects.filter(anchor=anchor).first() + if not deploy_board: + return Response( + {"error": "Invalid anchor"}, + status=status.HTTP_404_NOT_FOUND, + ) + + labels = Label.objects.filter( + workspace__slug=deploy_board.workspace.slug, + project_id=deploy_board.project_id, + ).values("id", "name", "color", "parent") + + return Response( + labels, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/space/views/module.py b/apiserver/plane/space/views/module.py new file mode 100644 index 000000000..f52f42331 --- /dev/null +++ b/apiserver/plane/space/views/module.py @@ -0,0 +1,35 @@ +# Third Party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.db.models import ( + DeployBoard, + Module, +) + + +class ProjectModulesEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, anchor): + deploy_board = DeployBoard.objects.filter(anchor=anchor).first() + if not deploy_board: + return Response( + {"error": "Invalid anchor"}, + status=status.HTTP_404_NOT_FOUND, + ) + + modules = Module.objects.filter( + workspace__slug=deploy_board.workspace.slug, + project_id=deploy_board.project_id, + ).values("id", "name") + + return Response( + modules, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/space/views/project.py b/apiserver/plane/space/views/project.py index 6f8977e03..0b8eab72b 100644 --- a/apiserver/plane/space/views/project.py +++ b/apiserver/plane/space/views/project.py @@ -12,10 +12,7 @@ from rest_framework.permissions import AllowAny # Module imports from .base import BaseAPIView from plane.app.serializers import DeployBoardSerializer -from plane.db.models import ( - Project, - DeployBoard, -) +from plane.db.models import Project, DeployBoard, ProjectMember class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView): @@ -76,3 +73,27 @@ class WorkspaceProjectAnchorEndpoint(BaseAPIView): ) serializer = DeployBoardSerializer(project_deploy_board) return Response(serializer.data, status=status.HTTP_200_OK) + + +class ProjectMembersEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, anchor): + deploy_board = DeployBoard.objects.filter(anchor=anchor).first() + + members = ProjectMember.objects.filter( + project=deploy_board.project, + workspace=deploy_board.workspace, + is_active=True, + ).values( + "id", + "member", + "member__first_name", + "member__last_name", + "member__display_name", + "project", + "workspace", + ) + return Response(members, status=status.HTTP_200_OK) diff --git a/apiserver/plane/space/views/state.py b/apiserver/plane/space/views/state.py new file mode 100644 index 000000000..853bf022c --- /dev/null +++ b/apiserver/plane/space/views/state.py @@ -0,0 +1,42 @@ +# Django imports +from django.db.models import Q + +# Third Party imports +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.db.models import ( + DeployBoard, + State, +) + + +class ProjectStatesEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request, anchor): + deploy_board = DeployBoard.objects.filter(anchor=anchor).first() + if not deploy_board: + return Response( + {"error": "Invalid anchor"}, + status=status.HTTP_404_NOT_FOUND, + ) + + states = ( + State.objects.filter( + ~Q(name="Triage"), + workspace__slug=deploy_board.workspace.slug, + project_id=deploy_board.project_id, + ) + .values("name", "group", "color", "id") + ) + + return Response( + states, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index e0d51a56b..f551569ed 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -154,6 +154,13 @@ def filter_priority(params, filter, method, prefix=""): ] if len(priorities) and "" not in priorities: filter[f"{prefix}priority__in"] = priorities + else: + if ( + params.get("priority", None) + and len(params.get("priority")) + and params.get("priority") != "null" + ): + filter[f"{prefix}priority__in"] = params.get("priority") return filter diff --git a/packages/constants/index.ts b/packages/constants/index.ts index 97ccf7649..66089416f 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -1 +1,2 @@ export * from "./auth"; +export * from "./issue"; \ No newline at end of file diff --git a/packages/constants/issue.ts b/packages/constants/issue.ts new file mode 100644 index 000000000..67f8af56f --- /dev/null +++ b/packages/constants/issue.ts @@ -0,0 +1,27 @@ +export const ALL_ISSUES = "All Issues"; + +export enum EIssueGroupByToServerOptions { + "state" = "state_id", + "priority" = "priority", + "labels" = "labels__id", + "state_detail.group" = "state__group", + "assignees" = "assignees__id", + "cycle" = "cycle_id", + "module" = "issue_module__module_id", + "target_date" = "target_date", + "project" = "project_id", + "created_by" = "created_by", +} + +export enum EServerGroupByToFilterOptions { + "state_id" = "state", + "priority" = "priority", + "labels__id" = "labels", + "state__group" = "state_group", + "assignees__id" = "assignees", + "cycle_id" = "cycle", + "issue_module__module_id" = "module", + "target_date" = "target_date", + "project_id" = "project", + "created_by" = "created_by", +} diff --git a/space/app/issues/[anchor]/page.tsx b/space/app/issues/[anchor]/page.tsx index 1b16def82..2bc37eecb 100644 --- a/space/app/issues/[anchor]/page.tsx +++ b/space/app/issues/[anchor]/page.tsx @@ -2,10 +2,11 @@ import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; +import useSWR from "swr"; // components import { IssuesLayoutsRoot } from "@/components/issues"; // hooks -import { usePublish } from "@/hooks/store"; +import { usePublish, useLabel, useStates } from "@/hooks/store"; type Props = { params: { @@ -19,6 +20,12 @@ const IssuesPage = observer((props: Props) => { // params const searchParams = useSearchParams(); const peekId = searchParams.get("peekId") || undefined; + // store + const { fetchStates } = useStates(); + const { fetchLabels } = useLabel(); + + useSWR(anchor ? `PUBLIC_STATES_${anchor}` : null, anchor ? () => fetchStates(anchor) : null); + useSWR(anchor ? `PUBLIC_LABELS_${anchor}` : null, anchor ? () => fetchLabels(anchor) : null); const publishSettings = usePublish(anchor); diff --git a/space/core/components/issues/filters/applied-filters/filters-list.tsx b/space/core/components/issues/filters/applied-filters/filters-list.tsx index 4216daec2..4a4ed4eda 100644 --- a/space/core/components/issues/filters/applied-filters/filters-list.tsx +++ b/space/core/components/issues/filters/applied-filters/filters-list.tsx @@ -3,8 +3,7 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; // types -import { IStateLite } from "@plane/types"; -import { IIssueLabel, TFilters } from "@/types/issue"; +import { TFilters } from "@/types/issue"; // components import { AppliedPriorityFilters } from "./priority"; import { AppliedStateFilters } from "./state"; @@ -13,14 +12,12 @@ type Props = { appliedFilters: TFilters; handleRemoveAllFilters: () => void; handleRemoveFilter: (key: keyof TFilters, value: string | null) => void; - labels?: IIssueLabel[] | undefined; - states?: IStateLite[] | undefined; }; export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); export const AppliedFiltersList: React.FC = observer((props) => { - const { appliedFilters = {}, handleRemoveAllFilters, handleRemoveFilter, states } = props; + const { appliedFilters = {}, handleRemoveAllFilters, handleRemoveFilter } = props; return (
@@ -52,10 +49,9 @@ export const AppliedFiltersList: React.FC = observer((props) => { /> )} */} - {filterKey === "state" && states && ( + {filterKey === "state" && ( handleRemoveFilter("state", val)} - states={states} values={filterValue ?? []} /> )} diff --git a/space/core/components/issues/filters/applied-filters/root.tsx b/space/core/components/issues/filters/applied-filters/root.tsx index 43024bb85..6bed90076 100644 --- a/space/core/components/issues/filters/applied-filters/root.tsx +++ b/space/core/components/issues/filters/applied-filters/root.tsx @@ -5,7 +5,7 @@ import cloneDeep from "lodash/cloneDeep"; import { observer } from "mobx-react"; import { useRouter } from "next/navigation"; // hooks -import { useIssue, useIssueFilter } from "@/hooks/store"; +import { useIssueFilter } from "@/hooks/store"; // store import { TIssueQueryFilters } from "@/types/issue"; // components @@ -21,7 +21,6 @@ export const IssueAppliedFilters: FC = observer((props) => const router = useRouter(); // store hooks const { getIssueFilters, initIssueFilters, updateIssueFilters } = useIssueFilter(); - const { states, labels } = useIssue(); // derived values const issueFilters = getIssueFilters(anchor); const activeLayout = issueFilters?.display_filters?.layout || undefined; @@ -65,14 +64,18 @@ export const IssueAppliedFilters: FC = observer((props) => ); const handleRemoveAllFilters = () => { - initIssueFilters(anchor, { - display_filters: { layout: activeLayout || "list" }, - filters: { - state: [], - priority: [], - labels: [], + initIssueFilters( + anchor, + { + display_filters: { layout: activeLayout || "list" }, + filters: { + state: [], + priority: [], + labels: [], + }, }, - }); + true + ); router.push(`/issues/${anchor}?${`board=${activeLayout || "list"}`}`); }; @@ -85,8 +88,6 @@ export const IssueAppliedFilters: FC = observer((props) => appliedFilters={appliedFilters || {}} handleRemoveFilter={handleFilters as any} handleRemoveAllFilters={handleRemoveAllFilters} - labels={labels ?? []} - states={states ?? []} />
); diff --git a/space/core/components/issues/filters/applied-filters/state.tsx b/space/core/components/issues/filters/applied-filters/state.tsx index 7d3b9ef57..6eeabd71f 100644 --- a/space/core/components/issues/filters/applied-filters/state.tsx +++ b/space/core/components/issues/filters/applied-filters/state.tsx @@ -2,19 +2,20 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; -// types -import { IStateLite } from "@plane/types"; // ui import { StateGroupIcon } from "@plane/ui"; +// hooks +import { useStates } from "@/hooks/store"; type Props = { handleRemove: (val: string) => void; - states: IStateLite[]; values: string[]; }; export const AppliedStateFilters: React.FC = observer((props) => { - const { handleRemove, states, values } = props; + const { handleRemove, values } = props; + + const { states } = useStates(); return ( <> diff --git a/space/core/components/issues/filters/root.tsx b/space/core/components/issues/filters/root.tsx index cd427e2e3..641cf007c 100644 --- a/space/core/components/issues/filters/root.tsx +++ b/space/core/components/issues/filters/root.tsx @@ -12,7 +12,7 @@ import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks -import { useIssue, useIssueFilter } from "@/hooks/store"; +import { useIssueFilter } from "@/hooks/store"; // types import { TIssueQueryFilters } from "@/types/issue"; @@ -26,7 +26,6 @@ export const IssueFiltersDropdown: FC = observer((pro const router = useRouter(); // hooks const { getIssueFilters, updateIssueFilters } = useIssueFilter(); - const { states, labels } = useIssue(); // derived values const issueFilters = getIssueFilters(anchor); const activeLayout = issueFilters?.display_filters?.layout || undefined; @@ -65,8 +64,6 @@ export const IssueFiltersDropdown: FC = observer((pro filters={issueFilters?.filters ?? {}} handleFilters={handleFilters as any} layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT?.[activeLayout]?.filters : []} - states={states ?? undefined} - labels={labels ?? undefined} /> diff --git a/space/core/components/issues/filters/selection.tsx b/space/core/components/issues/filters/selection.tsx index 3f3ccb037..2b5d54074 100644 --- a/space/core/components/issues/filters/selection.tsx +++ b/space/core/components/issues/filters/selection.tsx @@ -4,8 +4,7 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; import { Search, X } from "lucide-react"; // types -import { IStateLite } from "@plane/types"; -import { IIssueLabel, IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue"; +import { IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue"; // components import { FilterPriority, FilterState } from "."; @@ -13,12 +12,10 @@ type Props = { filters: IIssueFilterOptions; handleFilters: (key: keyof IIssueFilterOptions, value: string | string[]) => void; layoutDisplayFiltersOptions: TIssueFilterKeys[]; - labels?: IIssueLabel[] | undefined; - states?: IStateLite[] | undefined; }; export const FilterSelection: React.FC = observer((props) => { - const { filters, handleFilters, layoutDisplayFiltersOptions, states } = props; + const { filters, handleFilters, layoutDisplayFiltersOptions } = props; const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); @@ -63,7 +60,6 @@ export const FilterSelection: React.FC = observer((props) => { appliedFilters={filters.state ?? null} handleUpdate={(val) => handleFilters("state", val)} searchQuery={filtersSearchQuery} - states={states} /> )} diff --git a/space/core/components/issues/filters/state.tsx b/space/core/components/issues/filters/state.tsx index f61237eef..dcd76797a 100644 --- a/space/core/components/issues/filters/state.tsx +++ b/space/core/components/issues/filters/state.tsx @@ -1,22 +1,24 @@ "use client"; import React, { useState } from "react"; -// types -import { IStateLite } from "@plane/types"; +import { observer } from "mobx-react"; // ui import { Loader, StateGroupIcon } from "@plane/ui"; // components import { FilterHeader, FilterOption } from "@/components/issues/filters/helpers"; +// hooks +import { useStates } from "@/hooks/store"; type Props = { appliedFilters: string[] | null; handleUpdate: (val: string) => void; searchQuery: string; - states: IStateLite[] | undefined; }; -export const FilterState: React.FC = (props) => { - const { appliedFilters, handleUpdate, searchQuery, states } = props; +export const FilterState: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const { states } = useStates(); const [itemsToRender, setItemsToRender] = useState(5); const [previewEnabled, setPreviewEnabled] = useState(true); @@ -77,4 +79,4 @@ export const FilterState: React.FC = (props) => { )} ); -}; +}); diff --git a/space/core/components/issues/issue-layouts/kanban/block.tsx b/space/core/components/issues/issue-layouts/kanban/block.tsx index 4a94c4e77..e6f2733ab 100644 --- a/space/core/components/issues/issue-layouts/kanban/block.tsx +++ b/space/core/components/issues/issue-layouts/kanban/block.tsx @@ -9,18 +9,17 @@ import { IssueBlockDueDate, IssueBlockPriority, IssueBlockState } from "@/compon // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks -import { useIssueDetails, usePublish } from "@/hooks/store"; +import { useIssue, useIssueDetails, usePublish } from "@/hooks/store"; // interfaces -import { IIssue } from "@/types/issue"; type Props = { anchor: string; - issue: IIssue; - params: any; + issueId: string; }; export const IssueKanBanBlock: FC = observer((props) => { - const { anchor, issue } = props; + const { anchor, issueId } = props; + const { getIssueById } = useIssue(); const searchParams = useSearchParams(); // query params const board = searchParams.get("board"); @@ -31,12 +30,16 @@ export const IssueKanBanBlock: FC = observer((props) => { const { project_details } = usePublish(anchor); const { setPeekId } = useIssueDetails(); - const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels }); + const { queryParam } = queryParamGenerator({ board, peekId: issueId, priority, state, labels }); const handleBlockClick = () => { - setPeekId(issue.id); + setPeekId(issueId); }; + const issue = getIssueById(issueId); + + if (!issue) return <>; + return ( = observer((props) => { )} {/* state */} - {issue?.state_detail && ( + {issue?.state_id && (
- +
)} {/* due date */} {issue?.target_date && (
- +
)} diff --git a/space/core/components/issues/issue-layouts/kanban/column.tsx b/space/core/components/issues/issue-layouts/kanban/column.tsx new file mode 100644 index 000000000..f8b2a9eae --- /dev/null +++ b/space/core/components/issues/issue-layouts/kanban/column.tsx @@ -0,0 +1,70 @@ +import { useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +// components +import { Icon } from "@/components/ui"; +// hooks +import { useIssue } from "@/hooks/store"; +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; +// components +import { IssueKanBanBlock } from "./block"; +import { IssueKanBanHeader } from "./header"; + +type Props = { + anchor: string; + stateId: string; + issueIds: string[]; +}; + +export const Column = observer((props: Props) => { + const { anchor, stateId, issueIds } = props; + + const containerRef = useRef(null); + const [intersectionElement, setIntersectionElement] = useState(null); + + const { fetchNextPublicIssues, getPaginationData, getIssueLoader, getGroupIssueCount } = useIssue(); + + const loadMoreIssuesInThisGroup = useCallback(() => { + fetchNextPublicIssues(anchor, stateId); + }, [fetchNextPublicIssues, anchor, stateId]); + + const isPaginating = !!getIssueLoader(stateId); + const nextPageResults = getPaginationData(stateId, undefined)?.nextPageResults; + + useIntersectionObserver( + containerRef, + isPaginating ? null : intersectionElement, + loadMoreIssuesInThisGroup, + `0% 100% 100% 100%` + ); + + const groupIssueCount = getGroupIssueCount(stateId, undefined, false); + const shouldLoadMore = + nextPageResults === undefined && groupIssueCount !== undefined + ? issueIds?.length < groupIssueCount + : !!nextPageResults; + + return ( +
+
+ +
+
+ {issueIds && issueIds.length > 0 ? ( +
+ {issueIds.map((issueId) => ( + + ))} + {shouldLoadMore && ( +
+ )} +
+ ) : ( +
+ + No issues in this state +
+ )} +
+
+ ); +}); diff --git a/space/core/components/issues/issue-layouts/kanban/header.tsx b/space/core/components/issues/issue-layouts/kanban/header.tsx index e8182aa30..2c7f91dc7 100644 --- a/space/core/components/issues/issue-layouts/kanban/header.tsx +++ b/space/core/components/issues/issue-layouts/kanban/header.tsx @@ -1,25 +1,32 @@ "use client"; import { observer } from "mobx-react"; -// types -import { IStateLite } from "@plane/types"; // ui import { StateGroupIcon } from "@plane/ui"; +// hooks +import { useIssue, useStates } from "@/hooks/store"; type Props = { - state: IStateLite; + stateId: string; }; export const IssueKanBanHeader: React.FC = observer((props) => { - const { state } = props; + const { stateId } = props; + + const { getStateById } = useStates(); + const { getGroupIssueCount } = useIssue(); + + const state = getStateById(stateId); return (
- +
-
{state?.name}
- {/* {getCountOfIssuesByState(state.id)} */} +
{state?.name ?? "State"}
+ + {getGroupIssueCount(stateId, undefined, false) ?? 0} +
); }); diff --git a/space/core/components/issues/issue-layouts/kanban/root.tsx b/space/core/components/issues/issue-layouts/kanban/root.tsx index b73b65d33..5f43fc42a 100644 --- a/space/core/components/issues/issue-layouts/kanban/root.tsx +++ b/space/core/components/issues/issue-layouts/kanban/root.tsx @@ -2,12 +2,11 @@ import { FC } from "react"; import { observer } from "mobx-react"; -// components -import { IssueKanBanBlock, IssueKanBanHeader } from "@/components/issues"; -// ui -import { Icon } from "@/components/ui"; // mobx hook +import { TGroupedIssues } from "@plane/types"; +// hooks import { useIssue } from "@/hooks/store"; +import { Column } from "./column"; type Props = { anchor: string; @@ -16,34 +15,19 @@ type Props = { export const IssueKanbanLayoutRoot: FC = observer((props) => { const { anchor } = props; // store hooks - const { states, getFilteredIssuesByState } = useIssue(); + const { groupedIssueIds } = useIssue(); + + const groupedIssues = groupedIssueIds as TGroupedIssues | undefined; + + if (!groupedIssues) return <>; + + const issueGroupIds = Object.keys(groupedIssues); return (
- {states?.map((state) => { - const issues = getFilteredIssuesByState(state.id); - - return ( -
-
- -
-
- {issues && issues.length > 0 ? ( -
- {issues.map((issue) => ( - - ))} -
- ) : ( -
- - No issues in this state -
- )} -
-
- ); + {issueGroupIds?.map((stateId) => { + const issueIds = groupedIssues[stateId]; + return ; })}
); diff --git a/space/core/components/issues/issue-layouts/list/block.tsx b/space/core/components/issues/issue-layouts/list/block.tsx index a1f7e8297..905db2cc4 100644 --- a/space/core/components/issues/issue-layouts/list/block.tsx +++ b/space/core/components/issues/issue-layouts/list/block.tsx @@ -8,17 +8,16 @@ import { IssueBlockDueDate, IssueBlockLabels, IssueBlockPriority, IssueBlockStat // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; // hook -import { useIssueDetails, usePublish } from "@/hooks/store"; -// types -import { IIssue } from "@/types/issue"; +import { useIssue, useIssueDetails, usePublish } from "@/hooks/store"; type IssueListBlockProps = { anchor: string; - issue: IIssue; + issueId: string; }; export const IssueListLayoutBlock: FC = observer((props) => { - const { anchor, issue } = props; + const { anchor, issueId } = props; + const { getIssueById } = useIssue(); // query params const searchParams = useSearchParams(); const board = searchParams.get("board") || undefined; @@ -29,11 +28,15 @@ export const IssueListLayoutBlock: FC = observer((props) => const { setPeekId } = useIssueDetails(); const { project_details } = usePublish(anchor); - const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels }); + const { queryParam } = queryParamGenerator({ board, peekId: issueId, priority, state, labels }); const handleBlockClick = () => { - setPeekId(issue.id); + setPeekId(issueId); }; + const issue = getIssueById(issueId); + + if (!issue) return <>; + return ( = observer((props) => )} {/* state */} - {issue?.state_detail && ( + {issue?.state_id && (
- +
)} {/* labels */} - {issue?.label_details && issue?.label_details.length > 0 && ( + {issue?.label_ids && issue?.label_ids.length > 0 && (
- +
)} {/* due date */} {issue?.target_date && (
- +
)}
diff --git a/space/core/components/issues/issue-layouts/list/group.tsx b/space/core/components/issues/issue-layouts/list/group.tsx new file mode 100644 index 000000000..3e2d48385 --- /dev/null +++ b/space/core/components/issues/issue-layouts/list/group.tsx @@ -0,0 +1,59 @@ +import { useCallback } from "react"; +import { observer } from "mobx-react"; +// hooks +import { useIssue } from "@/hooks/store"; +// components +import { IssueListLayoutBlock } from "./block"; +import { IssueListLayoutHeader } from "./header"; + +type Props = { + anchor: string; + stateId: string; + issueIds: string[]; +}; + +export const Group = observer((props: Props) => { + const { anchor, stateId, issueIds } = props; + + const { fetchNextPublicIssues, getPaginationData, getIssueLoader, getGroupIssueCount } = useIssue(); + + const loadMoreIssuesInThisGroup = useCallback(() => { + fetchNextPublicIssues(anchor, stateId); + }, [stateId]); + + const isPaginating = !!getIssueLoader(stateId); + const nextPageResults = getPaginationData(stateId, undefined)?.nextPageResults; + + const groupIssueCount = getGroupIssueCount(stateId, undefined, false); + const shouldLoadMore = + nextPageResults === undefined && groupIssueCount !== undefined + ? issueIds?.length < groupIssueCount + : !!nextPageResults; + + return ( +
+ + {issueIds && issueIds.length > 0 ? ( +
+ {issueIds.map((issueId) => ( + + ))} + {isPaginating ? ( +
+ ) : ( + shouldLoadMore && ( +
+ Load More ↓ +
+ ) + )} +
+ ) : ( +
No issues.
+ )} +
+ ); +}); diff --git a/space/core/components/issues/issue-layouts/list/header.tsx b/space/core/components/issues/issue-layouts/list/header.tsx index 6ac0213ed..73625da9e 100644 --- a/space/core/components/issues/issue-layouts/list/header.tsx +++ b/space/core/components/issues/issue-layouts/list/header.tsx @@ -2,25 +2,32 @@ import React from "react"; import { observer } from "mobx-react"; -// types -import { IStateLite } from "@plane/types"; // ui import { StateGroupIcon } from "@plane/ui"; +// hooks +import { useIssue, useStates } from "@/hooks/store"; type Props = { - state: IStateLite; + stateId: string; }; export const IssueListLayoutHeader: React.FC = observer((props) => { - const { state } = props; + const { stateId } = props; + + const { getStateById } = useStates(); + const { getGroupIssueCount } = useIssue(); + + const state = getStateById(stateId); return ( -
+
- +
{state?.name}
- {/*
{count}
*/} +
+ {getGroupIssueCount(stateId, undefined, false) ?? 0} +
); }); diff --git a/space/core/components/issues/issue-layouts/list/root.tsx b/space/core/components/issues/issue-layouts/list/root.tsx index ec22e745a..215abe86f 100644 --- a/space/core/components/issues/issue-layouts/list/root.tsx +++ b/space/core/components/issues/issue-layouts/list/root.tsx @@ -1,10 +1,11 @@ "use client"; import { FC } from "react"; import { observer } from "mobx-react"; -// components -import { IssueListLayoutBlock, IssueListLayoutHeader } from "@/components/issues"; +// types +import { TGroupedIssues } from "@plane/types"; // mobx hook import { useIssue } from "@/hooks/store"; +import { Group } from "./group"; type Props = { anchor: string; @@ -13,27 +14,20 @@ type Props = { export const IssuesListLayoutRoot: FC = observer((props) => { const { anchor } = props; // store hooks - const { states, getFilteredIssuesByState } = useIssue(); + const { groupedIssueIds } = useIssue(); + + const groupedIssues = groupedIssueIds as TGroupedIssues | undefined; + + if (!groupedIssues) return <>; + + const issueGroupIds = Object.keys(groupedIssues); return ( <> - {states?.map((state) => { - const issues = getFilteredIssuesByState(state.id); + {issueGroupIds?.map((stateId) => { + const issueIds = groupedIssues[stateId]; - return ( -
- - {issues && issues.length > 0 ? ( -
- {issues.map((issue) => ( - - ))} -
- ) : ( -
No issues.
- )} -
- ); + return ; })} ); diff --git a/space/core/components/issues/issue-layouts/properties/due-date.tsx b/space/core/components/issues/issue-layouts/properties/due-date.tsx index 3b73973e7..30a441bfe 100644 --- a/space/core/components/issues/issue-layouts/properties/due-date.tsx +++ b/space/core/components/issues/issue-layouts/properties/due-date.tsx @@ -1,27 +1,31 @@ "use client"; +import { observer } from "mobx-react"; import { CalendarCheck2 } from "lucide-react"; -// types -import { TStateGroups } from "@plane/types"; // helpers import { cn } from "@/helpers/common.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; +// hooks +import { useStates } from "@/hooks/store"; type Props = { due_date: string; - group: TStateGroups; + stateId: string | undefined; }; -export const IssueBlockDueDate = (props: Props) => { - const { due_date, group } = props; +export const IssueBlockDueDate = observer((props: Props) => { + const { due_date, stateId } = props; + const { getStateById } = useStates(); + + const state = getStateById(stateId); return (
@@ -29,4 +33,4 @@ export const IssueBlockDueDate = (props: Props) => { {renderFormattedDate(due_date)}
); -}; +}); diff --git a/space/core/components/issues/issue-layouts/properties/labels.tsx b/space/core/components/issues/issue-layouts/properties/labels.tsx index 75c32c4a0..be920dbab 100644 --- a/space/core/components/issues/issue-layouts/properties/labels.tsx +++ b/space/core/components/issues/issue-layouts/properties/labels.tsx @@ -1,17 +1,41 @@ "use client"; -export const IssueBlockLabels = ({ labels }: any) => ( -
- {labels?.map((_label: any) => ( -
-
-
-
{_label?.name}
+import { observer } from "mobx-react"; +import { Tooltip } from "@plane/ui"; +import { useLabel } from "@/hooks/store"; + +type Props = { + labelIds: string[]; +}; + +export const IssueBlockLabels = observer(({ labelIds }: Props) => { + const { getLabelsByIds } = useLabel(); + + const labels = getLabelsByIds(labelIds); + + const labelsString = labels.map((label) => label.name).join(", "); + + return ( +
+ {labels.length === 1 ? ( +
+
+
+
{labels[0].name}
+
-
- ))} -
-); + ) : ( + +
+
+
{labels.length} Labels
+
+
+
+ )} +
+ ); +}); diff --git a/space/core/components/issues/issue-layouts/properties/state.tsx b/space/core/components/issues/issue-layouts/properties/state.tsx index 31e851c64..959c02ddb 100644 --- a/space/core/components/issues/issue-layouts/properties/state.tsx +++ b/space/core/components/issues/issue-layouts/properties/state.tsx @@ -1,13 +1,27 @@ "use client"; +import { observer } from "mobx-react"; // ui import { StateGroupIcon } from "@plane/ui"; +//hooks +import { useStates } from "@/hooks/store"; -export const IssueBlockState = ({ state }: any) => ( -
-
- -
{state?.name}
+type Props = { + stateId: string; +}; +export const IssueBlockState = observer(({ stateId }: Props) => { + const { getStateById } = useStates(); + + const state = getStateById(stateId); + + if (!state) return <>; + + return ( +
+
+ +
{state?.name}
+
-
-); + ); +}); diff --git a/space/core/components/issues/issue-layouts/root.tsx b/space/core/components/issues/issue-layouts/root.tsx index 59a50875d..73b9f3267 100644 --- a/space/core/components/issues/issue-layouts/root.tsx +++ b/space/core/components/issues/issue-layouts/root.tsx @@ -3,7 +3,6 @@ import { FC, useEffect } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; -import { useSearchParams } from "next/navigation"; import useSWR from "swr"; // components import { IssueKanbanLayoutRoot, IssuesListLayoutRoot } from "@/components/issues"; @@ -23,22 +22,22 @@ type Props = { export const IssuesLayoutsRoot: FC = observer((props) => { const { peekId, publishSettings } = props; - // query params - const searchParams = useSearchParams(); - const states = searchParams.get("states") || undefined; - const priority = searchParams.get("priority") || undefined; - const labels = searchParams.get("labels") || undefined; // store hooks const { getIssueFilters } = useIssueFilter(); - const { loader, issues, error, fetchPublicIssues } = useIssue(); + const { loader, groupedIssueIds, fetchPublicIssues } = useIssue(); const issueDetailStore = useIssueDetails(); // derived values const { anchor } = publishSettings; const issueFilters = anchor ? getIssueFilters(anchor) : undefined; + // derived values + const activeLayout = issueFilters?.display_filters?.layout || undefined; - useSWR( + const { error } = useSWR( anchor ? `PUBLIC_ISSUES_${anchor}` : null, - anchor ? () => fetchPublicIssues(anchor, { states, priority, labels }) : null + anchor + ? () => fetchPublicIssues(anchor, "init-loader", { groupedBy: "state", canGroup: true, perPageCount: 50 }) + : null, + { revalidateIfStale: false, revalidateOnFocus: false } ); useEffect(() => { @@ -47,16 +46,13 @@ export const IssuesLayoutsRoot: FC = observer((props) => { } }, [peekId, issueDetailStore]); - // derived values - const activeLayout = issueFilters?.display_filters?.layout || undefined; - if (!anchor) return null; return (
{peekId && } - {loader && !issues ? ( + {loader && !groupedIssueIds ? (
Loading...
) : ( <> diff --git a/space/core/components/issues/peek-overview/issue-details.tsx b/space/core/components/issues/peek-overview/issue-details.tsx index 97a659554..8c24ad644 100644 --- a/space/core/components/issues/peek-overview/issue-details.tsx +++ b/space/core/components/issues/peek-overview/issue-details.tsx @@ -1,6 +1,8 @@ +import { observer } from "mobx-react"; // components import { RichTextReadOnlyEditor } from "@/components/editor"; import { IssueReactions } from "@/components/issues/peek-overview"; +import { usePublish } from "@/hooks/store"; // types import { IIssue } from "@/types/issue"; @@ -9,15 +11,17 @@ type Props = { issueDetails: IIssue; }; -export const PeekOverviewIssueDetails: React.FC = (props) => { +export const PeekOverviewIssueDetails: React.FC = observer((props) => { const { anchor, issueDetails } = props; + const { project_details } = usePublish(anchor); + const description = issueDetails.description_html; return (
- {issueDetails.project_detail?.identifier}-{issueDetails?.sequence_id} + {project_details?.identifier}-{issueDetails?.sequence_id}

{issueDetails.name}

{description !== "" && description !== "

" && ( @@ -34,4 +38,4 @@ export const PeekOverviewIssueDetails: React.FC = (props) => {
); -}; +}); diff --git a/space/core/components/issues/peek-overview/issue-emoji-reactions.tsx b/space/core/components/issues/peek-overview/issue-emoji-reactions.tsx index d2a282ace..7a9851244 100644 --- a/space/core/components/issues/peek-overview/issue-emoji-reactions.tsx +++ b/space/core/components/issues/peek-overview/issue-emoji-reactions.tsx @@ -32,10 +32,10 @@ export const IssueEmojiReactions: React.FC = observer( const { data: user } = useUser(); const issueId = issueDetailsStore.peekId; - const reactions = issueId ? issueDetailsStore.details[issueId]?.reactions || [] : []; + const reactions = issueDetailsStore.details[issueId ?? ""]?.reaction_items ?? []; const groupedReactions = groupReactions(reactions, "reaction"); - const userReactions = reactions?.filter((r) => r.actor_detail.id === user?.id); + const userReactions = reactions.filter((r) => r.actor_details?.id === user?.id); const handleAddReaction = (reactionHex: string) => { if (!issueId) return; @@ -48,7 +48,7 @@ export const IssueEmojiReactions: React.FC = observer( }; const handleReactionClick = (reactionHex: string) => { - const userReaction = userReactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex); + const userReaction = userReactions?.find((r) => r.actor_details?.id === user?.id && r.reaction === reactionHex); if (userReaction) handleRemoveReaction(reactionHex); else handleAddReaction(reactionHex); }; @@ -78,9 +78,9 @@ export const IssueEmojiReactions: React.FC = observer( tooltipContent={
{reactions - .map((r) => r.actor_detail.display_name) - .splice(0, REACTIONS_LIMIT) - .join(", ")} + ?.map((r) => r?.actor_details?.display_name) + ?.splice(0, REACTIONS_LIMIT) + ?.join(", ")} {reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"}
} @@ -92,7 +92,7 @@ export const IssueEmojiReactions: React.FC = observer( else router.push(`/?next_path=${pathName}?${queryParam}`); }} className={`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${ - reactions?.some((r) => r.actor_detail.id === user?.id && r.reaction === reaction) + reactions.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80" }`} @@ -100,7 +100,7 @@ export const IssueEmojiReactions: React.FC = observer( {renderEmoji(reaction)} r.actor_detail.id === user?.id && r.reaction === reaction) + reactions.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction) ? "text-custom-primary-100" : "" } diff --git a/space/core/components/issues/peek-overview/issue-properties.tsx b/space/core/components/issues/peek-overview/issue-properties.tsx index 8b81f8c5e..0749f8519 100644 --- a/space/core/components/issues/peek-overview/issue-properties.tsx +++ b/space/core/components/issues/peek-overview/issue-properties.tsx @@ -1,5 +1,7 @@ "use client"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; import { CalendarCheck2, Signal } from "lucide-react"; // ui import { DoubleCircleIcon, StateGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; @@ -12,6 +14,8 @@ import { cn } from "@/helpers/common.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; import { copyTextToClipboard, addSpaceIfCamelCase } from "@/helpers/string.helper"; +// hooks +import { usePublish, useStates } from "@/hooks/store"; // types import { IIssue, IPeekMode } from "@/types/issue"; @@ -20,8 +24,13 @@ type Props = { mode?: IPeekMode; }; -export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mode }) => { - const state = issueDetails.state_detail; +export const PeekOverviewIssueProperties: React.FC = observer(({ issueDetails, mode }) => { + const { getStateById } = useStates(); + const state = getStateById(issueDetails?.state_id ?? undefined); + + const { anchor } = useParams(); + + const { project_details } = usePublish(anchor?.toString()); const priority = issueDetails.priority ? issuePriorityFilter(issueDetails.priority) : null; @@ -42,7 +51,7 @@ export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mod {mode === "full" && (
- {issueDetails.project_detail.identifier}-{issueDetails.sequence_id} + {project_details?.identifier}-{issueDetails.sequence_id}
- + {addSpaceIfCamelCase(state?.name ?? "")}
@@ -101,10 +110,7 @@ export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mod {issueDetails.target_date ? (
@@ -118,4 +124,4 @@ export const PeekOverviewIssueProperties: React.FC = ({ issueDetails, mod
); -}; +}); diff --git a/space/core/components/issues/peek-overview/issue-vote-reactions.tsx b/space/core/components/issues/peek-overview/issue-vote-reactions.tsx index 4e30e69cd..ccfe4967a 100644 --- a/space/core/components/issues/peek-overview/issue-vote-reactions.tsx +++ b/space/core/components/issues/peek-overview/issue-vote-reactions.tsx @@ -37,20 +37,20 @@ export const IssueVotes: React.FC = observer((props) => { const issueId = issueDetailsStore.peekId; - const votes = issueId ? issueDetailsStore.details[issueId]?.votes : []; + const votes = issueDetailsStore.details[issueId ?? ""]?.vote_items ?? []; - const allUpVotes = votes?.filter((vote) => vote.vote === 1); - const allDownVotes = votes?.filter((vote) => vote.vote === -1); + const allUpVotes = votes.filter((vote) => vote.vote === 1); + const allDownVotes = votes.filter((vote) => vote.vote === -1); - const isUpVotedByUser = allUpVotes?.some((vote) => vote.actor === user?.id); - const isDownVotedByUser = allDownVotes?.some((vote) => vote.actor === user?.id); + const isUpVotedByUser = allUpVotes.some((vote) => vote.actor_details?.id === user?.id); + const isDownVotedByUser = allDownVotes.some((vote) => vote.actor_details?.id === user?.id); const handleVote = async (e: any, voteValue: 1 | -1) => { if (!issueId) return; setIsSubmitting(true); - const actionPerformed = votes?.find((vote) => vote.actor === user?.id && vote.vote === voteValue); + const actionPerformed = votes?.find((vote) => vote.actor_details?.id === user?.id && vote.vote === voteValue); if (actionPerformed) await issueDetailsStore.removeIssueVote(anchor, issueId); else { @@ -76,7 +76,7 @@ export const IssueVotes: React.FC = observer((props) => { {allUpVotes.length > 0 ? ( <> {allUpVotes - .map((r) => r.actor_detail.display_name) + .map((r) => r.actor_details?.display_name) .splice(0, VOTES_LIMIT) .join(", ")} {allUpVotes.length > VOTES_LIMIT && " and " + (allUpVotes.length - VOTES_LIMIT) + " more"} @@ -116,7 +116,7 @@ export const IssueVotes: React.FC = observer((props) => { {allDownVotes.length > 0 ? ( <> {allDownVotes - .map((r) => r.actor_detail.display_name) + .map((r) => r.actor_details.display_name) .splice(0, VOTES_LIMIT) .join(", ")} {allDownVotes.length > VOTES_LIMIT && " and " + (allDownVotes.length - VOTES_LIMIT) + " more"} diff --git a/space/core/components/issues/peek-overview/layout.tsx b/space/core/components/issues/peek-overview/layout.tsx index 39f5d6216..fa1383827 100644 --- a/space/core/components/issues/peek-overview/layout.tsx +++ b/space/core/components/issues/peek-overview/layout.tsx @@ -33,12 +33,12 @@ export const IssuePeekOverview: FC = observer((props) => { const issueDetails = issueDetailStore.peekId && peekId ? issueDetailStore.details[peekId.toString()] : undefined; useEffect(() => { - if (anchor && peekId && issueStore.issues && issueStore.issues.length > 0) { + if (anchor && peekId && issueStore.groupedIssueIds) { if (!issueDetails) { issueDetailStore.fetchIssueDetails(anchor, peekId.toString()); } } - }, [anchor, issueDetailStore, issueDetails, peekId, issueStore.issues]); + }, [anchor, issueDetailStore, issueDetails, peekId, issueStore.groupedIssueIds]); const handleClose = () => { issueDetailStore.setPeekId(null); diff --git a/space/core/constants/issue.ts b/space/core/constants/issue.ts index 5d858a70e..e518e9ebb 100644 --- a/space/core/constants/issue.ts +++ b/space/core/constants/issue.ts @@ -75,4 +75,4 @@ export const issuePriorityFilter = (priorityKey: TIssuePriorities): TIssueFilter if (currentIssuePriority) return currentIssuePriority; return undefined; -}; +}; \ No newline at end of file diff --git a/space/core/hooks/store/index.ts b/space/core/hooks/store/index.ts index 3f82613d5..87b9d7317 100644 --- a/space/core/hooks/store/index.ts +++ b/space/core/hooks/store/index.ts @@ -5,3 +5,5 @@ export * from "./use-user"; export * from "./use-user-profile"; export * from "./use-issue-details"; export * from "./use-issue-filter"; +export * from "./use-state"; +export * from "./use-label"; diff --git a/space/core/hooks/store/use-issue.ts b/space/core/hooks/store/use-issue.ts index 641f05acf..1a9de1cb2 100644 --- a/space/core/hooks/store/use-issue.ts +++ b/space/core/hooks/store/use-issue.ts @@ -6,6 +6,6 @@ import { IIssueStore } from "@/store/issue.store"; export const useIssue = (): IIssueStore => { const context = useContext(StoreContext); - if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider"); + if (context === undefined) throw new Error("useIssue must be used within StoreProvider"); return context.issue; }; diff --git a/space/core/hooks/store/use-label.ts b/space/core/hooks/store/use-label.ts new file mode 100644 index 000000000..7786ba43b --- /dev/null +++ b/space/core/hooks/store/use-label.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import { IIssueLabelStore } from "@/store/label.store"; + +export const useLabel = (): IIssueLabelStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useLabel must be used within StoreProvider"); + return context.label; +}; diff --git a/space/core/hooks/store/use-state.ts b/space/core/hooks/store/use-state.ts new file mode 100644 index 000000000..f3f45d472 --- /dev/null +++ b/space/core/hooks/store/use-state.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// lib +import { StoreContext } from "@/lib/store-provider"; +// store +import { IStateStore } from "@/store/state.store"; + +export const useStates = (): IStateStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useState must be used within StoreProvider"); + return context.state; +}; diff --git a/space/core/hooks/use-clipboard-write-permission.tsx b/space/core/hooks/use-clipboard-write-permission.tsx index 0cafbb7ef..1f89b8291 100644 --- a/space/core/hooks/use-clipboard-write-permission.tsx +++ b/space/core/hooks/use-clipboard-write-permission.tsx @@ -6,6 +6,7 @@ const useClipboardWritePermission = () => { useEffect(() => { const checkClipboardWriteAccess = () => { navigator.permissions + //eslint-disable-next-line no-undef .query({ name: "clipboard-write" as PermissionName }) .then((result) => { if (result.state === "granted") { diff --git a/space/core/hooks/use-intersection-observer.tsx b/space/core/hooks/use-intersection-observer.tsx new file mode 100644 index 000000000..63ab31f37 --- /dev/null +++ b/space/core/hooks/use-intersection-observer.tsx @@ -0,0 +1,41 @@ +import { RefObject, useEffect } from "react"; + +export type UseIntersectionObserverProps = { + containerRef: RefObject | undefined; + elementRef: HTMLElement | null; + callback: () => void; + rootMargin?: string; +}; + +export const useIntersectionObserver = ( + containerRef: RefObject, + elementRef: HTMLElement | null, + callback: (() => void) | undefined, + rootMargin?: string +) => { + useEffect(() => { + if (elementRef) { + const observer = new IntersectionObserver( + (entries) => { + if (entries[entries.length - 1].isIntersecting) { + callback && callback(); + } + }, + { + root: containerRef?.current, + rootMargin, + } + ); + observer.observe(elementRef); + return () => { + if (elementRef) { + // eslint-disable-next-line react-hooks/exhaustive-deps + observer.unobserve(elementRef); + } + }; + } + // When i am passing callback as a dependency, it is causing infinite loop, + // Please make sure you fix this eslint lint disable error with caution + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rootMargin, callback, elementRef, containerRef.current]); +}; diff --git a/space/core/services/issue.service.ts b/space/core/services/issue.service.ts index f86481812..2f19b4f08 100644 --- a/space/core/services/issue.service.ts +++ b/space/core/services/issue.service.ts @@ -2,7 +2,7 @@ import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; // types -import { TIssuesResponse } from "@/types/issue"; +import { TIssuesResponse, IIssue } from "@/types/issue"; class IssueService extends APIService { constructor() { @@ -19,7 +19,7 @@ class IssueService extends APIService { }); } - async getIssueById(anchor: string, issueID: string): Promise { + async getIssueById(anchor: string, issueID: string): Promise { return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/`) .then((response) => response?.data) .catch((error) => { diff --git a/space/core/services/label.service.ts b/space/core/services/label.service.ts new file mode 100644 index 000000000..086215c0d --- /dev/null +++ b/space/core/services/label.service.ts @@ -0,0 +1,17 @@ +import { IIssueLabel } from "@plane/types"; +import { API_BASE_URL } from "@/helpers/common.helper"; +import { APIService } from "./api.service"; + +export class LabelService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getLabels(anchor: string): Promise { + return this.get(`api/public/anchor/${anchor}/labels/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/space/core/services/state.service.ts b/space/core/services/state.service.ts new file mode 100644 index 000000000..e2124675b --- /dev/null +++ b/space/core/services/state.service.ts @@ -0,0 +1,17 @@ +import { IState } from "@plane/types"; +import { API_BASE_URL } from "@/helpers/common.helper"; +import { APIService } from "./api.service"; + +export class StateService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getStates(anchor: string): Promise { + return this.get(`api/public/anchor/${anchor}/states/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/space/core/store/helpers/base-issues.store.ts b/space/core/store/helpers/base-issues.store.ts new file mode 100644 index 000000000..c61a67023 --- /dev/null +++ b/space/core/store/helpers/base-issues.store.ts @@ -0,0 +1,553 @@ +import concat from "lodash/concat"; +import get from "lodash/get"; +import isEmpty from "lodash/isEmpty"; +import set from "lodash/set"; +import uniq from "lodash/uniq"; +import update from "lodash/update"; +import { action, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// plane constants +import { ALL_ISSUES } from "@plane/constants"; +// types +import { + TIssueGroupByOptions, + TGroupedIssues, + TSubGroupedIssues, + TLoader, + IssuePaginationOptions, + TIssues, + TIssuePaginationData, + TGroupedIssueCount, + TPaginationData, +} from "@plane/types"; +// services +import IssueService from "@/services/issue.service"; +import { IIssue, TIssuesResponse } from "@/types/issue"; +import { IIssueFilterStore } from "../issue-filters.store"; +import { CoreRootStore } from "../root.store"; +// constants +// helpers + +export type TIssueDisplayFilterOptions = Exclude | "target_date"; + +export enum EIssueGroupedAction { + ADD = "ADD", + DELETE = "DELETE", + REORDER = "REORDER", +} + +export interface IBaseIssuesStore { + // observable + loader: Record; + issuesMap: Record; // Record defines issue_id as key and IIssue as value + // actions + addIssue(issues: IIssue[], shouldReplace?: boolean): void; + // helper methods + getIssueById(issueId: string): undefined | IIssue; + + fetchIssueById(anchorId: string, issueId: string): Promise; + + groupedIssueIds: TGroupedIssues | TSubGroupedIssues | undefined; // object to store Issue Ids based on group or subgroup + groupedIssueCount: TGroupedIssueCount; // map of groupId/subgroup and issue count of that particular group/subgroup + issuePaginationData: TIssuePaginationData; // map of groupId/subgroup and pagination Data of that particular group/subgroup + + // helper methods + getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; + getPaginationData(groupId: string | undefined, subGroupId: string | undefined): TPaginationData | undefined; + getIssueLoader(groupId?: string, subGroupId?: string): TLoader; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; +} + +export const ISSUE_FILTER_DEFAULT_DATA: Record = { + project: "project_id", + cycle: "cycle_id", + module: "module_ids", + state: "state_id", + "state_detail.group": "state_group" as keyof IIssue, // state_detail.group is only being used for state_group display, + priority: "priority", + labels: "label_ids", + created_by: "created_by", + assignees: "assignee_ids", + target_date: "target_date", +}; + +export abstract class BaseIssuesStore implements IBaseIssuesStore { + loader: Record = {}; + groupedIssueIds: TIssues | undefined = undefined; + issuePaginationData: TIssuePaginationData = {}; + issuesMap: Record = {}; // Record defines issue_id as key and TIssue as value + groupedIssueCount: TGroupedIssueCount = {}; + // + paginationOptions: IssuePaginationOptions | undefined = undefined; + + issueService; + // root store + rootIssueStore; + issueFilterStore; + + constructor(_rootStore: CoreRootStore, issueFilterStore: IIssueFilterStore) { + makeObservable(this, { + // observable + loader: observable, + groupedIssueIds: observable, + issuePaginationData: observable, + groupedIssueCount: observable, + + paginationOptions: observable, + // action + storePreviousPaginationValues: action.bound, + + onfetchIssues: action.bound, + onfetchNexIssues: action.bound, + clear: action.bound, + setLoader: action.bound, + }); + this.rootIssueStore = _rootStore; + this.issueFilterStore = issueFilterStore; + this.issueService = new IssueService(); + } + + getIssueIds = (groupId?: string, subGroupId?: string) => { + const groupedIssueIds = this.groupedIssueIds; + + if (!groupedIssueIds) return undefined; + + const allIssues = groupedIssueIds[ALL_ISSUES] ?? []; + if (allIssues && Array.isArray(allIssues)) { + return allIssues as string[]; + } + + if (groupId && groupedIssueIds?.[groupId] && Array.isArray(groupedIssueIds[groupId])) { + return (groupedIssueIds[groupId] ?? []) as string[]; + } + + if (groupId && subGroupId) { + return ((groupedIssueIds as TSubGroupedIssues)[groupId]?.[subGroupId] ?? []) as string[]; + } + + return undefined; + }; + + /** + * @description This method will add issues to the issuesMap + * @param {IIssue[]} issues + * @returns {void} + */ + addIssue = (issues: IIssue[], shouldReplace = false) => { + if (issues && issues.length <= 0) return; + runInAction(() => { + issues.forEach((issue) => { + if (!this.issuesMap[issue.id] || shouldReplace) set(this.issuesMap, issue.id, issue); + }); + }); + }; + + /** + * @description This method will return the issue from the issuesMap + * @param {string} issueId + * @returns {IIssue | undefined} + */ + getIssueById = computedFn((issueId: string) => { + if (!issueId || isEmpty(this.issuesMap) || !this.issuesMap[issueId]) return undefined; + return this.issuesMap[issueId]; + }); + + fetchIssueById = async (anchorId: string, issueId: string) => { + try { + const issueDetails = await this.issueService.getIssueById(anchorId, issueId); + + runInAction(() => { + set(this.issuesMap, [issueId], issueDetails); + }); + + return issueDetails; + } catch (e) { + console.error("error fetching issue details"); + } + }; + + /** + * Store the pagination data required for next subsequent issue pagination calls + * @param prevCursor cursor value of previous page + * @param nextCursor cursor value of next page + * @param nextPageResults boolean to indicate if the next page results exist i.e, have we reached end of pages + * @param groupId groupId and subGroupId to add the pagination data for the particular group/subgroup + * @param subGroupId + */ + setPaginationData( + prevCursor: string, + nextCursor: string, + nextPageResults: boolean, + groupId?: string, + subGroupId?: string + ) { + const cursorObject = { + prevCursor, + nextCursor, + nextPageResults, + }; + + set(this.issuePaginationData, [this.getGroupKey(groupId, subGroupId)], cursorObject); + } + + /** + * Sets the loader value of the particular groupId/subGroupId, or to ALL_ISSUES if both are undefined + * @param loaderValue + * @param groupId + * @param subGroupId + */ + setLoader(loaderValue: TLoader, groupId?: string, subGroupId?: string) { + runInAction(() => { + set(this.loader, this.getGroupKey(groupId, subGroupId), loaderValue); + }); + } + + /** + * gets the Loader value of particular group/subgroup/ALL_ISSUES + */ + getIssueLoader = (groupId?: string, subGroupId?: string) => get(this.loader, this.getGroupKey(groupId, subGroupId)); + + /** + * gets the pagination data of particular group/subgroup/ALL_ISSUES + */ + getPaginationData = computedFn( + (groupId: string | undefined, subGroupId: string | undefined): TPaginationData | undefined => + get(this.issuePaginationData, [this.getGroupKey(groupId, subGroupId)]) + ); + + /** + * gets the issue count of particular group/subgroup/ALL_ISSUES + * + * if isSubGroupCumulative is true, sum up all the issueCount of the subGroupId, across all the groupIds + */ + getGroupIssueCount = computedFn( + ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ): number | undefined => { + if (isSubGroupCumulative && subGroupId) { + const groupIssuesKeys = Object.keys(this.groupedIssueCount); + let subGroupCumulativeCount = 0; + + for (const groupKey of groupIssuesKeys) { + if (groupKey.includes(`_${subGroupId}`)) subGroupCumulativeCount += this.groupedIssueCount[groupKey]; + } + + return subGroupCumulativeCount; + } + + return get(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)]); + } + ); + + /** + * This Method is called after fetching the first paginated issues + * + * This method updates the appropriate issue list based on if groupByKey or subGroupByKey are defined + * If both groupByKey and subGroupByKey are not defined, then the issue list are added to another group called ALL_ISSUES + * @param issuesResponse Paginated Response received from the API + * @param options Pagination options + * @param workspaceSlug + * @param projectId + * @param id Id can be anything from cycleId, moduleId, viewId or userId based on the store + */ + onfetchIssues(issuesResponse: TIssuesResponse, options: IssuePaginationOptions) { + // Process the Issue Response to get the following data from it + const { issueList, groupedIssues, groupedIssueCount } = this.processIssueResponse(issuesResponse); + + // The Issue list is added to the main Issue Map + this.addIssue(issueList); + + // Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts + runInAction(() => { + this.updateGroupedIssueIds(groupedIssues, groupedIssueCount); + this.loader[this.getGroupKey()] = undefined; + }); + + // store Pagination options for next subsequent calls and data like next cursor etc + this.storePreviousPaginationValues(issuesResponse, options); + } + + /** + * This Method is called on the subsequent pagination calls after the first initial call + * + * This method updates the appropriate issue list based on if groupId or subgroupIds are Passed + * @param issuesResponse Paginated Response received from the API + * @param groupId + * @param subGroupId + */ + onfetchNexIssues(issuesResponse: TIssuesResponse, groupId?: string, subGroupId?: string) { + // Process the Issue Response to get the following data from it + const { issueList, groupedIssues, groupedIssueCount } = this.processIssueResponse(issuesResponse); + + // The Issue list is added to the main Issue Map + this.addIssue(issueList); + + // Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts + runInAction(() => { + this.updateGroupedIssueIds(groupedIssues, groupedIssueCount, groupId, subGroupId); + this.loader[this.getGroupKey(groupId, subGroupId)] = undefined; + }); + + // store Pagination data like next cursor etc + this.storePreviousPaginationValues(issuesResponse, undefined, groupId, subGroupId); + } + + /** + * Method called to clear out the current store + */ + clear(shouldClearPaginationOptions = true) { + runInAction(() => { + this.groupedIssueIds = undefined; + this.issuePaginationData = {}; + this.groupedIssueCount = {}; + if (shouldClearPaginationOptions) { + this.paginationOptions = undefined; + } + }); + } + + /** + * This method processes the issueResponse to provide data that can be used to update the store + * @param issueResponse + * @returns issueList, list of issue Data + * @returns groupedIssues, grouped issue Ids + * @returns groupedIssueCount, object containing issue counts of individual groups + */ + processIssueResponse(issueResponse: TIssuesResponse): { + issueList: IIssue[]; + groupedIssues: TIssues; + groupedIssueCount: TGroupedIssueCount; + } { + const issueResult = issueResponse?.results; + + // if undefined return empty objects + if (!issueResult) + return { + issueList: [], + groupedIssues: {}, + groupedIssueCount: {}, + }; + + //if is an array then it's an ungrouped response. return values with groupId as ALL_ISSUES + if (Array.isArray(issueResult)) { + return { + issueList: issueResult, + groupedIssues: { + [ALL_ISSUES]: issueResult.map((issue) => issue.id), + }, + groupedIssueCount: { + [ALL_ISSUES]: issueResponse.total_count, + }, + }; + } + + const issueList: IIssue[] = []; + const groupedIssues: TGroupedIssues | TSubGroupedIssues = {}; + const groupedIssueCount: TGroupedIssueCount = {}; + + // update total issue count to ALL_ISSUES + set(groupedIssueCount, [ALL_ISSUES], issueResponse.total_count); + + // loop through all the groupIds from issue Result + for (const groupId in issueResult) { + const groupIssuesObject = issueResult[groupId]; + const groupIssueResult = groupIssuesObject?.results; + + // if groupIssueResult is undefined then continue the loop + if (!groupIssueResult) continue; + + // set grouped Issue count of the current groupId + set(groupedIssueCount, [groupId], groupIssuesObject.total_results); + + // if groupIssueResult, the it is not subGrouped + if (Array.isArray(groupIssueResult)) { + // add the result to issueList + issueList.push(...groupIssueResult); + // set the issue Ids to the groupId path + set( + groupedIssues, + [groupId], + groupIssueResult.map((issue) => issue.id) + ); + continue; + } + + // loop through all the subGroupIds from issue Result + for (const subGroupId in groupIssueResult) { + const subGroupIssuesObject = groupIssueResult[subGroupId]; + const subGroupIssueResult = subGroupIssuesObject?.results; + + // if subGroupIssueResult is undefined then continue the loop + if (!subGroupIssueResult) continue; + + // set sub grouped Issue count of the current groupId + set(groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], subGroupIssuesObject.total_results); + + if (Array.isArray(subGroupIssueResult)) { + // add the result to issueList + issueList.push(...subGroupIssueResult); + // set the issue Ids to the [groupId, subGroupId] path + set( + groupedIssues, + [groupId, subGroupId], + subGroupIssueResult.map((issue) => issue.id) + ); + + continue; + } + } + } + + return { issueList, groupedIssues, groupedIssueCount }; + } + + /** + * This method is used to update the grouped issue Ids to it's respected lists and also to update group Issue Counts + * @param groupedIssues Object that contains list of issueIds with respect to their groups/subgroups + * @param groupedIssueCount Object the contains the issue count of each groups + * @param groupId groupId string + * @param subGroupId subGroupId string + * @returns updates the store with the values + */ + updateGroupedIssueIds( + groupedIssues: TIssues, + groupedIssueCount: TGroupedIssueCount, + groupId?: string, + subGroupId?: string + ) { + // if groupId exists and groupedIssues has ALL_ISSUES as a group, + // then it's an individual group/subgroup pagination + if (groupId && groupedIssues[ALL_ISSUES] && Array.isArray(groupedIssues[ALL_ISSUES])) { + const issueGroup = groupedIssues[ALL_ISSUES]; + const issueGroupCount = groupedIssueCount[ALL_ISSUES]; + const issuesPath = [groupId]; + // issuesPath is the path for the issue List in the Grouped Issue List + // issuePath is either [groupId] for grouped pagination or [groupId, subGroupId] for subGrouped pagination + if (subGroupId) issuesPath.push(subGroupId); + + // update the issue Count of the particular group/subGroup + set(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], issueGroupCount); + + // update the issue list in the issuePath + this.updateIssueGroup(issueGroup, issuesPath); + return; + } + + // if not in the above condition the it's a complete grouped pagination not individual group/subgroup pagination + // update total issue count as ALL_ISSUES count in `groupedIssueCount` object + set(this.groupedIssueCount, [ALL_ISSUES], groupedIssueCount[ALL_ISSUES]); + + // loop through the groups of groupedIssues. + for (const groupId in groupedIssues) { + const issueGroup = groupedIssues[groupId]; + const issueGroupCount = groupedIssueCount[groupId]; + + // update the groupId's issue count + set(this.groupedIssueCount, [groupId], issueGroupCount); + + // This updates the group issue list in the store, if the issueGroup is a string + const storeUpdated = this.updateIssueGroup(issueGroup, [groupId]); + // if issueGroup is indeed a string, continue + if (storeUpdated) continue; + + // if issueGroup is not a string, loop through the sub group Issues + for (const subGroupId in issueGroup) { + const issueSubGroup = (issueGroup as TGroupedIssues)[subGroupId]; + const issueSubGroupCount = groupedIssueCount[this.getGroupKey(groupId, subGroupId)]; + + // update the subGroupId's issue count + set(this.groupedIssueCount, [this.getGroupKey(groupId, subGroupId)], issueSubGroupCount); + // This updates the subgroup issue list in the store + this.updateIssueGroup(issueSubGroup, [groupId, subGroupId]); + } + } + } + + /** + * This Method is used to update the issue Id list at the particular issuePath + * @param groupedIssueIds could be an issue Id List for grouped issues or an object that contains a issue Id list in case of subGrouped + * @param issuePath array of string, to identify the path of the issueList to be updated with the above issue Id list + * @returns a boolean that indicates if the groupedIssueIds is indeed a array Id list, in which case the issue Id list is added to the store at issuePath + */ + updateIssueGroup(groupedIssueIds: TGroupedIssues | string[], issuePath: string[]): boolean { + if (!groupedIssueIds) return true; + + // if groupedIssueIds is an array, update the `groupedIssueIds` store at the issuePath + if (groupedIssueIds && Array.isArray(groupedIssueIds)) { + update(this, ["groupedIssueIds", ...issuePath], (issueIds: string[] = []) => + uniq(concat(issueIds, groupedIssueIds as string[])) + ); + // return true to indicate the store has been updated + return true; + } + + // return false to indicate the store has been updated and the groupedIssueIds is likely Object for subGrouped Issues + return false; + } + + /** + * This method is used to update the count of the issues at the path with the increment + * @param path issuePath, corresponding key is to be incremented + * @param increment + */ + updateIssueCount(accumulatedUpdatesForCount: { [key: string]: EIssueGroupedAction }) { + const updateKeys = Object.keys(accumulatedUpdatesForCount); + for (const updateKey of updateKeys) { + const update = accumulatedUpdatesForCount[updateKey]; + if (!update) continue; + + const increment = update === EIssueGroupedAction.ADD ? 1 : -1; + // get current count at the key + const issueCount = get(this.groupedIssueCount, updateKey) ?? 0; + // update the count at the key + set(this.groupedIssueCount, updateKey, issueCount + increment); + } + } + + /** + * This Method is called to store the pagination options and paginated data from response + * @param issuesResponse issue list response + * @param options pagination options to be stored for next page call + * @param groupId + * @param subGroupId + */ + storePreviousPaginationValues = ( + issuesResponse: TIssuesResponse, + options?: IssuePaginationOptions, + groupId?: string, + subGroupId?: string + ) => { + if (options) this.paginationOptions = options; + + this.setPaginationData( + issuesResponse.prev_cursor, + issuesResponse.next_cursor, + issuesResponse.next_page_results, + groupId, + subGroupId + ); + }; + + /** + * returns, + * A compound key, if both groupId & subGroupId are defined + * groupId, only if groupId is defined + * ALL_ISSUES, if both groupId & subGroupId are not defined + * @param groupId + * @param subGroupId + * @returns + */ + getGroupKey = (groupId?: string, subGroupId?: string) => { + if (groupId && subGroupId && subGroupId !== "null") return `${groupId}_${subGroupId}`; + + if (groupId) return groupId; + + return ALL_ISSUES; + }; +} diff --git a/space/core/store/helpers/filter.helpers.ts b/space/core/store/helpers/filter.helpers.ts new file mode 100644 index 000000000..001f08024 --- /dev/null +++ b/space/core/store/helpers/filter.helpers.ts @@ -0,0 +1,63 @@ +import { EIssueGroupByToServerOptions, EServerGroupByToFilterOptions } from "@plane/constants"; +import { IssuePaginationOptions, TIssueParams } from "@plane/types"; + +/** + * This Method is used to construct the url params along with paginated values + * @param filterParams params generated from filters + * @param options pagination options + * @param cursor cursor if exists + * @param groupId groupId if to fetch By group + * @param subGroupId groupId if to fetch By sub group + * @returns + */ +export const getPaginationParams = ( + filterParams: Partial> | undefined, + options: IssuePaginationOptions, + cursor: string | undefined, + groupId?: string, + subGroupId?: string +) => { + // if cursor exists, use the cursor. If it doesn't exist construct the cursor based on per page count + const pageCursor = cursor ? cursor : groupId ? `${options.perPageCount}:1:0` : `${options.perPageCount}:0:0`; + + // pagination params + const paginationParams: Partial> = { + ...filterParams, + cursor: pageCursor, + per_page: options.perPageCount.toString(), + }; + + // If group by is specifically sent through options, like that for calendar layout, use that to group + if (options.groupedBy) { + paginationParams.group_by = EIssueGroupByToServerOptions[options.groupedBy]; + } + + // If before and after dates are sent from option to filter by then, add them to filter the options + if (options.after && options.before) { + paginationParams["target_date"] = `${options.after};after,${options.before};before`; + } + + // If groupId is passed down, add a filter param for that group Id + if (groupId) { + const groupBy = paginationParams["group_by"] as EIssueGroupByToServerOptions | undefined; + delete paginationParams["group_by"]; + + if (groupBy) { + const groupByFilterOption = EServerGroupByToFilterOptions[groupBy]; + paginationParams[groupByFilterOption] = groupId; + } + } + + // If subGroupId is passed down, add a filter param for that subGroup Id + if (subGroupId) { + const subGroupBy = paginationParams["sub_group_by"] as EIssueGroupByToServerOptions | undefined; + delete paginationParams["sub_group_by"]; + + if (subGroupBy) { + const subGroupByFilterOption = EServerGroupByToFilterOptions[subGroupBy]; + paginationParams[subGroupByFilterOption] = subGroupId; + } + } + + return paginationParams; +}; diff --git a/space/core/store/issue-detail.store.ts b/space/core/store/issue-detail.store.ts index 03ba4bd86..f17e689ca 100644 --- a/space/core/store/issue-detail.store.ts +++ b/space/core/store/issue-detail.store.ts @@ -1,3 +1,4 @@ +import set from "lodash/set"; import { makeObservable, observable, action, runInAction } from "mobx"; import { v4 as uuidv4 } from "uuid"; // services @@ -97,7 +98,7 @@ export class IssueDetailStore implements IIssueDetailStore { this.loader = true; this.error = null; - const issueDetails = this.rootStore.issue.issues?.find((i) => i.id === issueID); + const issueDetails = await this.rootStore.issue.fetchIssueById(anchor, issueID); const commentsResponse = await this.issueService.getIssueComments(anchor, issueID); if (issueDetails) { @@ -119,17 +120,11 @@ export class IssueDetailStore implements IIssueDetailStore { addIssueComment = async (anchor: string, issueID: string, data: any) => { try { - const issueDetails = this.rootStore.issue.issues?.find((i) => i.id === issueID); + const issueDetails = this.rootStore.issue.getIssueById(issueID); const issueCommentResponse = await this.issueService.createIssueComment(anchor, issueID, data); if (issueDetails) { runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...issueDetails, - comments: [...this.details[issueID].comments, issueCommentResponse], - }, - }; + set(this.details, [issueID, "comments"], [...this.details[issueID].comments, issueCommentResponse]); }); } return issueCommentResponse; @@ -267,21 +262,17 @@ export class IssueDetailStore implements IIssueDetailStore { addIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => { try { runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - reactions: [ - ...this.details[issueID].reactions, - { - id: uuidv4(), - issue: issueID, - reaction: reactionHex, - actor_detail: this.rootStore.user.currentActor, - }, - ], - }, - }; + set( + this.details, + [issueID, "reaction_items"], + [ + ...this.details[issueID].reaction_items, + { + reaction: reactionHex, + actor_detail: this.rootStore.user.currentActor, + }, + ] + ); }); await this.issueService.createIssueReaction(anchor, issueID, { @@ -291,31 +282,19 @@ export class IssueDetailStore implements IIssueDetailStore { console.log("Failed to add issue vote"); const issueReactions = await this.issueService.getIssueReactions(anchor, issueID); runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - reactions: issueReactions, - }, - }; + set(this.details, [issueID, "reaction_items"], issueReactions); }); } }; removeIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => { try { - const newReactions = this.details[issueID].reactions.filter( - (_r) => !(_r.reaction === reactionHex && _r.actor_detail.id === this.rootStore.user.data?.id) + const newReactions = this.details[issueID].reaction_items.filter( + (_r) => !(_r.reaction === reactionHex && _r.actor_details.id === this.rootStore.user.data?.id) ); runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - reactions: newReactions, - }, - }; + set(this.details, [issueID, "reaction_items"], newReactions); }); await this.issueService.deleteIssueReaction(anchor, issueID, reactionHex); @@ -323,13 +302,7 @@ export class IssueDetailStore implements IIssueDetailStore { console.log("Failed to remove issue reaction"); const reactions = await this.issueService.getIssueReactions(anchor, issueID); runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - reactions: reactions, - }, - }; + set(this.details, [issueID, "reaction_items"], reactions); }); } }; @@ -341,25 +314,19 @@ export class IssueDetailStore implements IIssueDetailStore { if (!projectID || !workspaceSlug) throw new Error("Publish settings not found"); const newVote: IVote = { - actor: this.rootStore.user.data?.id ?? "", - actor_detail: this.rootStore.user.currentActor, - issue: issueID, - project: projectID, - workspace: workspaceSlug, + actor_details: this.rootStore.user.currentActor, vote: data.vote, }; - const filteredVotes = this.details[issueID].votes.filter((v) => v.actor !== this.rootStore.user.data?.id); + const filteredVotes = this.details[issueID].vote_items.filter( + (v) => v.actor_details?.id !== this.rootStore.user.data?.id + ); try { runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - votes: [...filteredVotes, newVote], - }, - }; + runInAction(() => { + set(this.details, [issueID, "vote_items"], [...filteredVotes, newVote]); + }); }); await this.issueService.createIssueVote(anchor, issueID, data); @@ -368,29 +335,19 @@ export class IssueDetailStore implements IIssueDetailStore { const issueVotes = await this.issueService.getIssueVotes(anchor, issueID); runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - votes: issueVotes, - }, - }; + set(this.details, [issueID, "vote_items"], issueVotes); }); } }; removeIssueVote = async (anchor: string, issueID: string) => { - const newVotes = this.details[issueID].votes.filter((v) => v.actor !== this.rootStore.user.data?.id); + const newVotes = this.details[issueID].vote_items.filter( + (v) => v.actor_details?.id !== this.rootStore.user.data?.id + ); try { runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - votes: newVotes, - }, - }; + set(this.details, [issueID, "vote_items"], newVotes); }); await this.issueService.deleteIssueVote(anchor, issueID); @@ -399,13 +356,7 @@ export class IssueDetailStore implements IIssueDetailStore { const issueVotes = await this.issueService.getIssueVotes(anchor, issueID); runInAction(() => { - this.details = { - ...this.details, - [issueID]: { - ...this.details[issueID], - votes: issueVotes, - }, - }; + set(this.details, [issueID, "vote_items"], issueVotes); }); } }; diff --git a/space/core/store/issue-filters.store.ts b/space/core/store/issue-filters.store.ts index 9e2361671..0c589dc4d 100644 --- a/space/core/store/issue-filters.store.ts +++ b/space/core/store/issue-filters.store.ts @@ -3,6 +3,8 @@ import isEqual from "lodash/isEqual"; import set from "lodash/set"; import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; +// plane types +import { IssuePaginationOptions, TIssueParams } from "@plane/types"; // constants import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; // store @@ -15,6 +17,7 @@ import { TIssueQueryFiltersParams, TIssueFilterKeys, } from "@/types/issue"; +import { getPaginationParams } from "./helpers/filter.helpers"; export interface IIssueFilterStore { // observables @@ -27,13 +30,20 @@ export interface IIssueFilterStore { getAppliedFilters: (anchor: string) => TIssueQueryFiltersParams | undefined; // actions updateLayoutOptions: (layout: TIssueLayoutOptions) => void; - initIssueFilters: (anchor: string, filters: TIssueFilters) => void; + initIssueFilters: (anchor: string, filters: TIssueFilters, shouldFetchIssues?: boolean) => void; updateIssueFilters: ( anchor: string, filterKind: K, filterKey: keyof TIssueFilters[K], filters: TIssueFilters[K][typeof filterKey] ) => Promise; + getFilterParams: ( + options: IssuePaginationOptions, + anchor: string, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => Partial>; } export class IssueFilterStore implements IIssueFilterStore { @@ -114,14 +124,27 @@ export class IssueFilterStore implements IIssueFilterStore { // actions updateLayoutOptions = (options: TIssueLayoutOptions) => set(this, ["layoutOptions"], options); - initIssueFilters = async (anchor: string, initFilters: TIssueFilters) => { + initIssueFilters = async (anchor: string, initFilters: TIssueFilters, shouldFetchIssues: boolean = false) => { if (this.filters === undefined) runInAction(() => (this.filters = {})); if (this.filters && initFilters) set(this.filters, [anchor], initFilters); - const appliedFilters = this.getAppliedFilters(anchor); - await this.store.issue.fetchPublicIssues(anchor, appliedFilters); + if (shouldFetchIssues) await this.store.issue.fetchPublicIssuesWithExistingPagination(anchor, "mutation"); }; + getFilterParams = computedFn( + ( + options: IssuePaginationOptions, + anchor: string, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => { + const filterParams = this.getAppliedFilters(anchor); + const paginationParams = getPaginationParams(filterParams, options, cursor, groupId, subGroupId); + return paginationParams; + } + ); + updateIssueFilters = async ( anchor: string, filterKind: K, @@ -135,7 +158,6 @@ export class IssueFilterStore implements IIssueFilterStore { if (this.filters) set(this.filters, [anchor, filterKind, filterKey], filterValue); }); - const appliedFilters = this.getAppliedFilters(anchor); - await this.store.issue.fetchPublicIssues(anchor, appliedFilters); + if (filterKey !== "layout") await this.store.issue.fetchPublicIssuesWithExistingPagination(anchor, "mutation"); }; } diff --git a/space/core/store/issue.store.ts b/space/core/store/issue.store.ts index 80f5f26bd..087ea392d 100644 --- a/space/core/store/issue.store.ts +++ b/space/core/store/issue.store.ts @@ -1,62 +1,38 @@ -import { observable, action, makeObservable, runInAction } from "mobx"; -import { computedFn } from "mobx-utils"; +import { action, makeObservable, runInAction } from "mobx"; // types -import { IStateLite } from "@plane/types"; +import { IssuePaginationOptions, TLoader } from "@plane/types"; // services import IssueService from "@/services/issue.service"; // store import { CoreRootStore } from "@/store/root.store"; // types -import { IIssue, IIssueLabel } from "@/types/issue"; +import { BaseIssuesStore, IBaseIssuesStore } from "./helpers/base-issues.store"; -export interface IIssueStore { - loader: boolean; - error: any; - // observables - issues: IIssue[]; - states: IStateLite[]; - labels: IIssueLabel[]; - // filter observables - filteredStates: string[]; - filteredLabels: string[]; - filteredPriorities: string[]; +export interface IIssueStore extends IBaseIssuesStore { // actions - fetchPublicIssues: (anchor: string, params: any) => Promise; - // helpers - getCountOfIssuesByState: (stateID: string) => number; - getFilteredIssuesByState: (stateID: string) => IIssue[]; + fetchPublicIssues: ( + anchor: string, + loadType: TLoader, + options: IssuePaginationOptions, + isExistingPaginationOptions?: boolean + ) => Promise; + fetchNextPublicIssues: (anchor: string, groupId?: string, subGroupId?: string) => Promise; + fetchPublicIssuesWithExistingPagination: (anchor: string, loadType?: TLoader) => Promise; } -export class IssueStore implements IIssueStore { - loader: boolean = false; - error: any | null = null; - // observables - states: IStateLite[] = []; - labels: IIssueLabel[] = []; - issues: IIssue[] = []; - // filter observables - filteredStates: string[] = []; - filteredLabels: string[] = []; - filteredPriorities: string[] = []; +export class IssueStore extends BaseIssuesStore implements IIssueStore { // root store rootStore: CoreRootStore; // services issueService: IssueService; constructor(_rootStore: CoreRootStore) { + super(_rootStore, _rootStore.issueFilter); makeObservable(this, { - loader: observable.ref, - error: observable, - // observables - states: observable, - labels: observable, - issues: observable, - // filter observables - filteredStates: observable, - filteredLabels: observable, - filteredPriorities: observable, // actions fetchPublicIssues: action, + fetchNextPublicIssues: action, + fetchPublicIssuesWithExistingPagination: action, }); this.rootStore = _rootStore; @@ -68,45 +44,69 @@ export class IssueStore implements IIssueStore { * @param {string} anchor * @param params */ - fetchPublicIssues = async (anchor: string, params: any) => { + fetchPublicIssues = async ( + anchor: string, + loadType: TLoader = "init-loader", + options: IssuePaginationOptions, + isExistingPaginationOptions: boolean = false + ) => { try { + // set loader and clear store runInAction(() => { - this.loader = true; - this.error = null; + this.setLoader(loadType); }); + this.clear(!isExistingPaginationOptions); + + const params = this.rootStore.issueFilter.getFilterParams(options, anchor, undefined, undefined, undefined); const response = await this.issueService.fetchPublicIssues(anchor, params); - if (response) { - runInAction(() => { - this.states = response.states; - this.labels = response.labels; - this.issues = response.issues; - this.loader = false; - }); - } + // after fetching issues, call the base method to process the response further + this.onfetchIssues(response, options); } catch (error) { - this.loader = false; - this.error = error; + this.setLoader(undefined); + throw error; + } + }; + + fetchNextPublicIssues = async (anchor: string, groupId?: string, subGroupId?: string) => { + const cursorObject = this.getPaginationData(groupId, subGroupId); + // if there are no pagination options and the next page results do not exist the return + if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return; + try { + // set Loader + this.setLoader("pagination", groupId, subGroupId); + + // get params from stored pagination options + const params = this.rootStore.issueFilter.getFilterParams( + this.paginationOptions, + anchor, + cursorObject?.nextCursor, + groupId, + subGroupId + ); + // call the fetch issues API with the params for next page in issues + const response = await this.issueService.fetchPublicIssues(anchor, params); + + // after the next page of issues are fetched, call the base method to process the response + this.onfetchNexIssues(response, groupId, subGroupId); + } catch (error) { + // set Loader as undefined if errored out + this.setLoader(undefined, groupId, subGroupId); throw error; } }; /** - * @description get total count of issues under a particular state - * @param {string} stateID - * @returns {number} + * This Method exists to fetch the first page of the issues with the existing stored pagination + * This is useful for refetching when filters, groupBy, orderBy etc changes + * @param workspaceSlug + * @param projectId + * @param loadType + * @returns */ - getCountOfIssuesByState = computedFn( - (stateID: string) => this.issues?.filter((issue) => issue.state == stateID).length || 0 - ); - - /** - * @description get array of issues under a particular state - * @param {string} stateID - * @returns {IIssue[]} - */ - getFilteredIssuesByState = computedFn( - (stateID: string) => this.issues?.filter((issue) => issue.state == stateID) || [] - ); + fetchPublicIssuesWithExistingPagination = async (anchor: string, loadType: TLoader = "mutation") => { + if (!this.paginationOptions) return; + return await this.fetchPublicIssues(anchor, loadType, this.paginationOptions, true); + }; } diff --git a/space/core/store/label.store.ts b/space/core/store/label.store.ts new file mode 100644 index 000000000..e705aa4d2 --- /dev/null +++ b/space/core/store/label.store.ts @@ -0,0 +1,63 @@ +import set from "lodash/set"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { IIssueLabel } from "@plane/types"; +import { LabelService } from "@/services/label.service"; +import { CoreRootStore } from "./root.store"; + +export interface IIssueLabelStore { + // observables + labels: IIssueLabel[] | undefined; + // computed actions + getLabelById: (labelId: string | undefined) => IIssueLabel | undefined; + getLabelsByIds: (labelIds: string[]) => IIssueLabel[]; + // fetch actions + fetchLabels: (anchor: string) => Promise; +} + +export class LabelStore implements IIssueLabelStore { + labelMap: Record = {}; + labelService: LabelService; + rootStore: CoreRootStore; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + // observables + labelMap: observable, + // computed + labels: computed, + // fetch action + fetchLabels: action, + }); + this.labelService = new LabelService(); + this.rootStore = _rootStore; + } + + get labels() { + return Object.values(this.labelMap); + } + + getLabelById = (labelId: string | undefined) => (labelId ? this.labelMap[labelId] : undefined); + + getLabelsByIds = (labelIds: string[]) => { + const currLabels = []; + for (const labelId of labelIds) { + const label = this.getLabelById(labelId); + if (label) { + currLabels.push(label); + } + } + + return currLabels; + }; + + fetchLabels = async (anchor: string) => { + const labelsResponse = await this.labelService.getLabels(anchor); + runInAction(() => { + this.labelMap = {}; + for (const label of labelsResponse) { + set(this.labelMap, [label.id], label); + } + }); + return labelsResponse; + }; +} diff --git a/space/core/store/root.store.ts b/space/core/store/root.store.ts index acd8c3b59..3f3ad5bb5 100644 --- a/space/core/store/root.store.ts +++ b/space/core/store/root.store.ts @@ -5,8 +5,10 @@ import { IssueDetailStore, IIssueDetailStore } from "@/store/issue-detail.store" import { IssueStore, IIssueStore } from "@/store/issue.store"; import { IUserStore, UserStore } from "@/store/user.store"; import { IssueFilterStore, IIssueFilterStore } from "./issue-filters.store"; +import { IIssueLabelStore, LabelStore } from "./label.store"; import { IMentionsStore, MentionsStore } from "./mentions.store"; import { IPublishListStore, PublishListStore } from "./publish/publish_list.store"; +import { IStateStore, StateStore } from "./state.store"; enableStaticRendering(typeof window === "undefined"); @@ -16,6 +18,8 @@ export class CoreRootStore { issue: IIssueStore; issueDetail: IIssueDetailStore; mentionStore: IMentionsStore; + state: IStateStore; + label: IIssueLabelStore; issueFilter: IIssueFilterStore; publishList: IPublishListStore; @@ -25,6 +29,8 @@ export class CoreRootStore { this.issue = new IssueStore(this); this.issueDetail = new IssueDetailStore(this); this.mentionStore = new MentionsStore(this); + this.state = new StateStore(this); + this.label = new LabelStore(this); this.issueFilter = new IssueFilterStore(this); this.publishList = new PublishListStore(this); } @@ -43,6 +49,8 @@ export class CoreRootStore { this.issue = new IssueStore(this); this.issueDetail = new IssueDetailStore(this); this.mentionStore = new MentionsStore(this); + this.state = new StateStore(this); + this.label = new LabelStore(this); this.issueFilter = new IssueFilterStore(this); this.publishList = new PublishListStore(this); } diff --git a/space/core/store/state.store.ts b/space/core/store/state.store.ts new file mode 100644 index 000000000..36a0ceaf7 --- /dev/null +++ b/space/core/store/state.store.ts @@ -0,0 +1,40 @@ +import { action, makeObservable, observable, runInAction } from "mobx"; +import { IState } from "@plane/types"; +import { StateService } from "@/services/state.service"; +import { CoreRootStore } from "./root.store"; + +export interface IStateStore { + // observables + states: IState[] | undefined; + // computed actions + getStateById: (stateId: string | undefined) => IState | undefined; + // fetch actions + fetchStates: (anchor: string) => Promise; +} + +export class StateStore implements IStateStore { + states: IState[] | undefined = undefined; + stateService: StateService; + rootStore: CoreRootStore; + + constructor(_rootStore: CoreRootStore) { + makeObservable(this, { + // observables + states: observable, + // fetch action + fetchStates: action, + }); + this.stateService = new StateService(); + this.rootStore = _rootStore; + } + + getStateById = (stateId: string | undefined) => this.states?.find((state) => state.id === stateId); + + fetchStates = async (anchor: string) => { + const statesResponse = await this.stateService.getStates(anchor); + runInAction(() => { + this.states = statesResponse; + }); + return statesResponse; + }; +} diff --git a/space/core/types/issue.d.ts b/space/core/types/issue.d.ts index b9676810e..00d6d505e 100644 --- a/space/core/types/issue.d.ts +++ b/space/core/types/issue.d.ts @@ -1,4 +1,4 @@ -import { IStateLite, IWorkspaceLite, TIssue, TIssuePriorities, TStateGroups } from "@plane/types"; +import { IWorkspaceLite, TIssue, TIssuePriorities, TStateGroups } from "@plane/types"; export type TIssueLayout = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt"; export type TIssueLayoutOptions = { @@ -33,31 +33,61 @@ export type TIssueQueryFilters = Partial; export type TIssueQueryFiltersParams = Partial>; -export type TIssuesResponse = { - states: IStateLite[]; - labels: IIssueLabel[]; - issues: IIssue[]; -}; - export interface IIssue - extends Pick { + extends Pick< + TIssue, + | "description_html" + | "created_by" + | "id" + | "name" + | "priority" + | "state_id" + | "project_id" + | "sequence_id" + | "sort_order" + | "start_date" + | "target_date" + | "cycle_id" + | "module_ids" + | "label_ids" + | "assignee_ids" + > { comments: Comment[]; - label_details: any; - project: string; - project_detail: any; - reactions: IIssueReaction[]; - state: string; - state_detail: { - id: string; - name: string; - group: TIssueGroupKey; - color: string; - }; - votes: IVote[]; + reaction_items: IIssueReaction[]; + vote_items: IVote[]; } export type IPeekMode = "side" | "modal" | "full"; +type TIssueResponseResults = + | IIssue[] + | { + [key: string]: { + results: + | IIssue[] + | { + [key: string]: { + results: IIssue[]; + total_results: number; + }; + }; + total_results: number; + }; + }; + +export type TIssuesResponse = { + grouped_by: string; + next_cursor: string; + prev_cursor: string; + next_page_results: boolean; + prev_page_results: boolean; + total_count: number; + count: number; + total_pages: number; + extra_stats: null; + results: TIssueResponseResults; +}; + export interface IIssueLabel { id: string; name: string; @@ -66,12 +96,8 @@ export interface IIssueLabel { } export interface IVote { - issue: string; vote: -1 | 1; - workspace: string; - project: string; - actor: string; - actor_detail: ActorDetail; + actor_details: ActorDetail; } export interface Comment { @@ -102,9 +128,7 @@ export interface Comment { } export interface IIssueReaction { - actor_detail: ActorDetail; - id: string; - issue: string; + actor_details: ActorDetail; reaction: string; } @@ -112,8 +136,8 @@ export interface ActorDetail { avatar?: string; display_name?: string; first_name?: string; - id?: string; is_bot?: boolean; + id?: string; last_name?: string; } diff --git a/space/helpers/emoji.helper.tsx b/space/helpers/emoji.helper.tsx index d5f9d1b5a..1619d6c0d 100644 --- a/space/helpers/emoji.helper.tsx +++ b/space/helpers/emoji.helper.tsx @@ -17,19 +17,16 @@ export const renderEmoji = ( else return isNaN(parseInt(emoji)) ? emoji : String.fromCodePoint(parseInt(emoji)); }; -export const groupReactions: (reactions: any[], key: string) => { [key: string]: any[] } = ( - reactions: any, - key: string -) => { +export const groupReactions = (reactions: T[], key: string) => { const groupedReactions = reactions.reduce( - (acc: any, reaction: any) => { + (acc: { [key: string]: T[] }, reaction: any) => { if (!acc[reaction[key]]) { acc[reaction[key]] = []; } acc[reaction[key]].push(reaction); return acc; }, - {} as { [key: string]: any[] } + {} as { [key: string]: T[] } ); return groupedReactions; diff --git a/space/package.json b/space/package.json index c59f35589..43f547583 100644 --- a/space/package.json +++ b/space/package.json @@ -20,6 +20,7 @@ "@plane/editor": "*", "@plane/types": "*", "@plane/ui": "*", + "@plane/constants": "*", "@sentry/nextjs": "^8", "axios": "^1.3.4", "clsx": "^2.0.0", @@ -62,4 +63,4 @@ "tailwind-config-custom": "*", "tsconfig": "*" } -} +} \ No newline at end of file diff --git a/web/core/components/issues/issue-layouts/calendar/base-calendar-root.tsx b/web/core/components/issues/issue-layouts/calendar/base-calendar-root.tsx index 33bb3f82c..296174fcc 100644 --- a/web/core/components/issues/issue-layouts/calendar/base-calendar-root.tsx +++ b/web/core/components/issues/issue-layouts/calendar/base-calendar-root.tsx @@ -3,12 +3,13 @@ import { FC, useCallback, useEffect } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +import { EIssueGroupByToServerOptions } from "@plane/constants"; import { TGroupedIssues } from "@plane/types"; // components import { TOAST_TYPE, setToast } from "@plane/ui"; import { CalendarChart } from "@/components/issues"; //constants -import { EIssuesStoreType, EIssueGroupByToServerOptions } from "@/constants/issue"; +import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // hooks import { useIssues, useUser, useCalendarView } from "@/hooks/store"; diff --git a/web/core/components/issues/issue-layouts/gantt/base-gantt-root.tsx b/web/core/components/issues/issue-layouts/gantt/base-gantt-root.tsx index db7c79c24..82720a3be 100644 --- a/web/core/components/issues/issue-layouts/gantt/base-gantt-root.tsx +++ b/web/core/components/issues/issue-layouts/gantt/base-gantt-root.tsx @@ -1,13 +1,15 @@ import React, { useCallback, useEffect } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +// plane constants +import { ALL_ISSUES } from "@plane/constants"; import { TIssue } from "@plane/types"; // hooks import { ChartDataType, GanttChartRoot, IBlockUpdateData, IssueGanttSidebar } from "@/components/gantt-chart"; import { getMonthChartItemPositionWidthInMonth } from "@/components/gantt-chart/views"; import { GanttQuickAddIssueForm, IssueGanttBlock } from "@/components/issues"; //constants -import { ALL_ISSUES, EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; +import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; import { getIssueBlocksStructure } from "@/helpers/issue.helper"; //hooks diff --git a/web/core/components/issues/issue-layouts/list/default.tsx b/web/core/components/issues/issue-layouts/list/default.tsx index ad66a56c9..a8fd66a86 100644 --- a/web/core/components/issues/issue-layouts/list/default.tsx +++ b/web/core/components/issues/issue-layouts/list/default.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef } from "react"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; import { observer } from "mobx-react"; +// plane constants +import { ALL_ISSUES } from "@plane/constants"; // types import { GroupByColumnTypes, @@ -15,8 +17,7 @@ import { } from "@plane/types"; // components import { MultipleSelectGroup } from "@/components/core"; -// constants -import { ALL_ISSUES } from "@/constants/issue"; + // hooks import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; diff --git a/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx b/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index 6f1c5156b..eeae019cb 100644 --- a/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -3,6 +3,8 @@ import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; import { useParams, useSearchParams } from "next/navigation"; import useSWR from "swr"; +// plane constants +import { ALL_ISSUES } from "@plane/constants"; import { IIssueDisplayFilterOptions } from "@plane/types"; // hooks // components @@ -12,7 +14,6 @@ import { AllIssueQuickActions } from "@/components/issues/issue-layouts/quick-ac import { SpreadsheetLayoutLoader } from "@/components/ui"; // constants import { - ALL_ISSUES, EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, diff --git a/web/core/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx b/web/core/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx index 8e3bdcf95..83481134e 100644 --- a/web/core/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx +++ b/web/core/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx @@ -1,9 +1,11 @@ import { FC, useCallback, useEffect } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +// plane constants +import { ALL_ISSUES } from "@plane/constants"; import { IIssueDisplayFilterOptions } from "@plane/types"; // hooks -import { ALL_ISSUES, EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; +import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // hooks import { useIssues, useUser } from "@/hooks/store"; diff --git a/web/core/constants/issue.ts b/web/core/constants/issue.ts index ead1f43d8..fd31350c7 100644 --- a/web/core/constants/issue.ts +++ b/web/core/constants/issue.ts @@ -11,9 +11,6 @@ import { TIssueTypeFilters, } from "@plane/types"; - -export const ALL_ISSUES = "All Issues"; - export const DRAG_ALLOWED_GROUPS: TIssueGroupByOptions[] = [ "state", "priority", @@ -460,32 +457,6 @@ export const groupReactionEmojis = (reactions: any) => { return groupedEmojis; }; -export enum EIssueGroupByToServerOptions { - "state" = "state_id", - "priority" = "priority", - "labels" = "labels__id", - "state_detail.group" = "state__group", - "assignees" = "assignees__id", - "cycle" = "cycle_id", - "module" = "issue_module__module_id", - "target_date" = "target_date", - "project" = "project_id", - "created_by" = "created_by", -} - -export enum EServerGroupByToFilterOptions { - "state_id" = "state", - "priority" = "priority", - "labels__id" = "labels", - "state__group" = "state_group", - "assignees__id" = "assignees", - "cycle_id" = "cycle", - "issue_module__module_id" = "module", - "target_date" = "target_date", - "project_id" = "project", - "created_by" = "created_by", -} - export enum EActivityFilterType { COMMENT = "COMMENT", ACTIVITY = "ACTIVITY", @@ -500,4 +471,4 @@ export const ACTIVITY_FILTER_TYPE_OPTIONS = [ value: EActivityFilterType.ACTIVITY, label: "Updates", }, -]; +]; \ No newline at end of file diff --git a/web/core/store/issue/cycle/issue.store.ts b/web/core/store/issue/cycle/issue.store.ts index 14460b44e..41dacd8a0 100644 --- a/web/core/store/issue/cycle/issue.store.ts +++ b/web/core/store/issue/cycle/issue.store.ts @@ -6,8 +6,10 @@ import set from "lodash/set"; import uniq from "lodash/uniq"; import update from "lodash/update"; import { action, observable, makeObservable, runInAction } from "mobx"; -// types import { computedFn } from "mobx-utils"; +// types +// plane constants +import { ALL_ISSUES } from "@plane/constants"; import { TIssue, TLoader, @@ -16,7 +18,6 @@ import { ViewFlags, TBulkOperationsPayload, } from "@plane/types"; -import { ALL_ISSUES } from "@/constants/issue"; import { BaseIssuesStore, IBaseIssuesStore } from "../helpers/base-issues.store"; import { IIssueRootStore } from "../root.store"; import { ICycleIssuesFilter } from "./filter.store"; diff --git a/web/core/store/issue/helpers/base-issues-utils.ts b/web/core/store/issue/helpers/base-issues-utils.ts index 273355a73..a9fc639b4 100644 --- a/web/core/store/issue/helpers/base-issues-utils.ts +++ b/web/core/store/issue/helpers/base-issues-utils.ts @@ -1,7 +1,7 @@ import isEmpty from "lodash/isEmpty"; import uniq from "lodash/uniq"; +import { ALL_ISSUES } from "@plane/constants"; import { TIssue } from "@plane/types"; -import { ALL_ISSUES } from "@/constants/issue"; import { EIssueGroupedAction } from "./base-issues.store"; /** diff --git a/web/core/store/issue/helpers/base-issues.store.ts b/web/core/store/issue/helpers/base-issues.store.ts index 2f9601493..984d6f355 100644 --- a/web/core/store/issue/helpers/base-issues.store.ts +++ b/web/core/store/issue/helpers/base-issues.store.ts @@ -13,6 +13,7 @@ import update from "lodash/update"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // types +import { ALL_ISSUES } from "@plane/constants"; import { TIssue, TIssueGroupByOptions, @@ -28,7 +29,7 @@ import { TPaginationData, TBulkOperationsPayload, } from "@plane/types"; -import { ALL_ISSUES, EIssueLayoutTypes, ISSUE_PRIORITIES } from "@/constants/issue"; +import { EIssueLayoutTypes, ISSUE_PRIORITIES } from "@/constants/issue"; import { convertToISODateString } from "@/helpers/date-time.helper"; import { CycleService } from "@/services/cycle.service"; import { IssueArchiveService, IssueDraftService, IssueService } from "@/services/issue"; diff --git a/web/core/store/issue/helpers/issue-filter-helper.store.ts b/web/core/store/issue/helpers/issue-filter-helper.store.ts index 380e1268f..8ba9e47ea 100644 --- a/web/core/store/issue/helpers/issue-filter-helper.store.ts +++ b/web/core/store/issue/helpers/issue-filter-helper.store.ts @@ -1,4 +1,5 @@ import isEmpty from "lodash/isEmpty"; +import { EIssueGroupByToServerOptions, EServerGroupByToFilterOptions } from "@plane/constants"; // types import { IIssueDisplayFilterOptions, @@ -12,12 +13,7 @@ import { TStaticViewTypes, } from "@plane/types"; // constants -import { - EIssueFilterType, - EIssuesStoreType, - EIssueGroupByToServerOptions, - EServerGroupByToFilterOptions, -} from "@/constants/issue"; +import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; // helpers import { getComputedDisplayFilters, getComputedDisplayProperties } from "@/helpers/issue.helper"; // lib