[WEB-1255] chore: Refactor existing Space app for project publish (#5107)

* chore: paginated the issues in space app

* chore: storing query using filters

* chore: added filters for priority

* chore: issue view model save function

* chore: votes and reactions added in issues endpoint

* chore: added filters in the public endpoint

* chore: issue detail endpoint

* chore: added labels, modules and assignees

* refactor existing project publish in space app

* fix clear all filters in space App

* chore: removed the extra serialier

* remove optional chaining and fallback to an empty array

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
rahulramesha 2024-07-15 18:35:45 +05:30 committed by GitHub
parent 22671ec8a7
commit 08d9e95a86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 2245 additions and 651 deletions

View file

@ -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"
}
}
}

View file

@ -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:

View file

@ -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)

View file

@ -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}>"

View file

@ -1,5 +1,9 @@
from .user import UserLiteSerializer
from .issue import LabelLiteSerializer, StateLiteSerializer
from .issue import (
LabelLiteSerializer,
StateLiteSerializer,
IssuePublicSerializer,
)
from .state import StateSerializer, StateLiteSerializer

View file

@ -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

View file

@ -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/<str:anchor>/cycles/",
ProjectCyclesEndpoint.as_view(),
name="project-cycles",
),
path(
"anchor/<str:anchor>/modules/",
ProjectModulesEndpoint.as_view(),
name="project-modules",
),
path(
"anchor/<str:anchor>/states/",
ProjectStatesEndpoint.as_view(),
name="project-states",
),
path(
"anchor/<str:anchor>/labels/",
ProjectLabelsEndpoint.as_view(),
name="project-labels",
),
path(
"anchor/<str:anchor>/members/",
ProjectMembersEndpoint.as_view(),
name="project-members",
),
]

View file

@ -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 []

View file

@ -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

View file

@ -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,
)

View file

@ -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)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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)

View file

@ -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,
)

View file

@ -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

View file

@ -1 +1,2 @@
export * from "./auth";
export * from "./issue";

View file

@ -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",
}

View file

@ -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);

View file

@ -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<Props> = observer((props) => {
const { appliedFilters = {}, handleRemoveAllFilters, handleRemoveFilter, states } = props;
const { appliedFilters = {}, handleRemoveAllFilters, handleRemoveFilter } = props;
return (
<div className="flex flex-wrap items-stretch gap-2">
@ -52,10 +49,9 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
/>
)} */}
{filterKey === "state" && states && (
{filterKey === "state" && (
<AppliedStateFilters
handleRemove={(val) => handleRemoveFilter("state", val)}
states={states}
values={filterValue ?? []}
/>
)}

View file

@ -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<TIssueAppliedFilters> = 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<TIssueAppliedFilters> = 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<TIssueAppliedFilters> = observer((props) =>
appliedFilters={appliedFilters || {}}
handleRemoveFilter={handleFilters as any}
handleRemoveAllFilters={handleRemoveAllFilters}
labels={labels ?? []}
states={states ?? []}
/>
</div>
);

View file

@ -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<Props> = observer((props) => {
const { handleRemove, states, values } = props;
const { handleRemove, values } = props;
const { states } = useStates();
return (
<>

View file

@ -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<IssueFiltersDropdownProps> = 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<IssueFiltersDropdownProps> = observer((pro
filters={issueFilters?.filters ?? {}}
handleFilters={handleFilters as any}
layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT?.[activeLayout]?.filters : []}
states={states ?? undefined}
labels={labels ?? undefined}
/>
</FiltersDropdown>
</div>

View file

@ -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<Props> = 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<Props> = observer((props) => {
appliedFilters={filters.state ?? null}
handleUpdate={(val) => handleFilters("state", val)}
searchQuery={filtersSearchQuery}
states={states}
/>
</div>
)}

View file

@ -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> = (props) => {
const { appliedFilters, handleUpdate, searchQuery, states } = props;
export const FilterState: React.FC<Props> = 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> = (props) => {
)}
</>
);
};
});

View file

@ -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<Props> = 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<Props> = 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 (
<Link
href={`/issues/${anchor}?${queryParam}`}
@ -61,15 +64,15 @@ export const IssueKanBanBlock: FC<Props> = observer((props) => {
</div>
)}
{/* state */}
{issue?.state_detail && (
{issue?.state_id && (
<div className="flex-shrink-0">
<IssueBlockState state={issue?.state_detail} />
<IssueBlockState stateId={issue?.state_id} />
</div>
)}
{/* due date */}
{issue?.target_date && (
<div className="flex-shrink-0">
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
<IssueBlockDueDate due_date={issue?.target_date} stateId={issue?.state_id ?? undefined} />
</div>
)}
</div>

View file

@ -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<HTMLDivElement | null>(null);
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(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 (
<div key={stateId} className="relative flex h-full w-[340px] flex-shrink-0 flex-col">
<div className="flex-shrink-0">
<IssueKanBanHeader stateId={stateId} />
</div>
<div className="hide-vertical-scrollbar h-full w-full overflow-hidden overflow-y-auto" ref={containerRef}>
{issueIds && issueIds.length > 0 ? (
<div className="space-y-3 px-2 pb-2">
{issueIds.map((issueId) => (
<IssueKanBanBlock key={issueId} anchor={anchor} issueId={issueId} />
))}
{shouldLoadMore && (
<div className="w-full h-[100px] bg-custom-background-80 animate-pulse" ref={setIntersectionElement} />
)}
</div>
) : (
<div className="flex items-center justify-center gap-2 pt-10 text-center text-sm font-medium text-custom-text-200">
<Icon iconName="stack" />
No issues in this state
</div>
)}
</div>
</div>
);
});

View file

@ -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<Props> = observer((props) => {
const { state } = props;
const { stateId } = props;
const { getStateById } = useStates();
const { getGroupIssueCount } = useIssue();
const state = getStateById(stateId);
return (
<div className="flex items-center gap-2 px-2 pb-2">
<div className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
<StateGroupIcon stateGroup={state.group} color={state.color} height="14" width="14" />
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} height="14" width="14" />
</div>
<div className="mr-1 truncate font-medium capitalize text-custom-text-200">{state?.name}</div>
{/* <span className="flex-shrink-0 rounded-full text-custom-text-300">{getCountOfIssuesByState(state.id)}</span> */}
<div className="mr-1 truncate font-medium capitalize text-custom-text-200">{state?.name ?? "State"}</div>
<span className="flex-shrink-0 rounded-full text-custom-text-300">
{getGroupIssueCount(stateId, undefined, false) ?? 0}
</span>
</div>
);
});

View file

@ -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<Props> = 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 (
<div className="relative flex h-full w-full gap-3 overflow-hidden overflow-x-auto">
{states?.map((state) => {
const issues = getFilteredIssuesByState(state.id);
return (
<div key={state.id} className="relative flex h-full w-[340px] flex-shrink-0 flex-col">
<div className="flex-shrink-0">
<IssueKanBanHeader state={state} />
</div>
<div className="hide-vertical-scrollbar h-full w-full overflow-hidden overflow-y-auto">
{issues && issues.length > 0 ? (
<div className="space-y-3 px-2 pb-2">
{issues.map((issue) => (
<IssueKanBanBlock key={issue.id} anchor={anchor} issue={issue} params={{}} />
))}
</div>
) : (
<div className="flex items-center justify-center gap-2 pt-10 text-center text-sm font-medium text-custom-text-200">
<Icon iconName="stack" />
No issues in this state
</div>
)}
</div>
</div>
);
{issueGroupIds?.map((stateId) => {
const issueIds = groupedIssues[stateId];
return <Column key={stateId} anchor={anchor} stateId={stateId} issueIds={issueIds} />;
})}
</div>
);

View file

@ -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<IssueListBlockProps> = 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<IssueListBlockProps> = 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 (
<Link
href={`/issues/${anchor}?${queryParam}`}
@ -60,23 +63,23 @@ export const IssueListLayoutBlock: FC<IssueListBlockProps> = observer((props) =>
)}
{/* state */}
{issue?.state_detail && (
{issue?.state_id && (
<div className="flex-shrink-0">
<IssueBlockState state={issue?.state_detail} />
<IssueBlockState stateId={issue?.state_id} />
</div>
)}
{/* labels */}
{issue?.label_details && issue?.label_details.length > 0 && (
{issue?.label_ids && issue?.label_ids.length > 0 && (
<div className="flex-shrink-0">
<IssueBlockLabels labels={issue?.label_details} />
<IssueBlockLabels labelIds={issue?.label_ids} />
</div>
)}
{/* due date */}
{issue?.target_date && (
<div className="flex-shrink-0">
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
<IssueBlockDueDate due_date={issue?.target_date} stateId={issue?.state_id ?? undefined} />
</div>
)}
</div>

View file

@ -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 (
<div key={stateId} className="relative w-full">
<IssueListLayoutHeader stateId={stateId} />
{issueIds && issueIds.length > 0 ? (
<div className="divide-y divide-custom-border-200">
{issueIds.map((issueId) => (
<IssueListLayoutBlock key={issueId} anchor={anchor} issueId={issueId} />
))}
{isPaginating ? (
<div className="w-full h-[46px] bg-custom-background-80 animate-pulse" />
) : (
shouldLoadMore && (
<div
className="w-full min-h-[45px] bg-custom-background-100 p-3 text-sm border-b-[1px] cursor-pointer text-custom-text-350 hover:text-custom-text-300"
onClick={loadMoreIssuesInThisGroup}
>
Load More &darr;
</div>
)
)}
</div>
) : (
<div className="bg-custom-background-100 p-3 text-sm text-custom-text-400">No issues.</div>
)}
</div>
);
});

View file

@ -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<Props> = observer((props) => {
const { state } = props;
const { stateId } = props;
const { getStateById } = useStates();
const { getGroupIssueCount } = useIssue();
const state = getStateById(stateId);
return (
<div className="flex items-center gap-2 p-3">
<div className="flex sticky top-0 items-center gap-2 p-3 bg-custom-background-90 z-[1] border-b-[1px] border-custom-border-200">
<div className="flex h-3.5 w-3.5 items-center justify-center">
<StateGroupIcon stateGroup={state.group} color={state.color} height="14" width="14" />
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} height="14" width="14" />
</div>
<div className="mr-1 font-medium capitalize">{state?.name}</div>
{/* <div className="text-sm font-medium text-custom-text-200">{count}</div> */}
<div className="text-sm font-medium text-custom-text-200">
{getGroupIssueCount(stateId, undefined, false) ?? 0}
</div>
</div>
);
});

View file

@ -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<Props> = 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 (
<div key={state.id} className="relative w-full">
<IssueListLayoutHeader state={state} />
{issues && issues.length > 0 ? (
<div className="divide-y divide-custom-border-200">
{issues.map((issue) => (
<IssueListLayoutBlock key={issue.id} anchor={anchor} issue={issue} />
))}
</div>
) : (
<div className="bg-custom-background-100 p-3 text-sm text-custom-text-400">No issues.</div>
)}
</div>
);
return <Group key={stateId} anchor={anchor} stateId={stateId} issueIds={issueIds} />;
})}
</>
);

View file

@ -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 (
<div
className={cn(
"flex items-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs text-custom-text-100",
{
"text-red-500": shouldHighlightIssueDueDate(due_date, group),
"text-red-500": shouldHighlightIssueDueDate(due_date, state?.group),
}
)}
>
@ -29,4 +33,4 @@ export const IssueBlockDueDate = (props: Props) => {
{renderFormattedDate(due_date)}
</div>
);
};
});

View file

@ -1,17 +1,41 @@
"use client";
export const IssueBlockLabels = ({ labels }: any) => (
<div className="relative flex flex-wrap items-center gap-1">
{labels?.map((_label: any) => (
<div
key={_label?.id}
className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
>
<div className="flex items-center gap-1.5 text-custom-text-200">
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: `${_label?.color}` }} />
<div className="text-xs">{_label?.name}</div>
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 (
<div className="relative flex flex-wrap items-center gap-1">
{labels.length === 1 ? (
<div
key={labels[0].id}
className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
>
<div className="flex items-center gap-1.5 text-custom-text-200">
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: `${labels[0].color}` }} />
<div className="text-xs">{labels[0].name}</div>
</div>
</div>
</div>
))}
</div>
);
) : (
<Tooltip tooltipContent={labelsString}>
<div className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm">
<div className="flex items-center gap-1.5 text-custom-text-200">
<div className="text-xs">{labels.length} Labels</div>
</div>
</div>
</Tooltip>
)}
</div>
);
});

View file

@ -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) => (
<div className="flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs shadow-sm duration-300 focus:outline-none">
<div className="flex w-full items-center gap-1.5">
<StateGroupIcon stateGroup={state.group} color={state.color} />
<div className="text-xs">{state?.name}</div>
type Props = {
stateId: string;
};
export const IssueBlockState = observer(({ stateId }: Props) => {
const { getStateById } = useStates();
const state = getStateById(stateId);
if (!state) return <></>;
return (
<div className="flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs shadow-sm duration-300 focus:outline-none">
<div className="flex w-full items-center gap-1.5">
<StateGroupIcon stateGroup={state.group} color={state.color} />
<div className="text-xs">{state?.name}</div>
</div>
</div>
</div>
);
);
});

View file

@ -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<Props> = 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<Props> = observer((props) => {
}
}, [peekId, issueDetailStore]);
// derived values
const activeLayout = issueFilters?.display_filters?.layout || undefined;
if (!anchor) return null;
return (
<div className="relative h-full w-full overflow-hidden">
{peekId && <IssuePeekOverview anchor={anchor} peekId={peekId} />}
{loader && !issues ? (
{loader && !groupedIssueIds ? (
<div className="py-10 text-center text-sm text-custom-text-100">Loading...</div>
) : (
<>

View file

@ -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> = (props) => {
export const PeekOverviewIssueDetails: React.FC<Props> = observer((props) => {
const { anchor, issueDetails } = props;
const { project_details } = usePublish(anchor);
const description = issueDetails.description_html;
return (
<div className="space-y-2">
<h6 className="text-base font-medium text-custom-text-400">
{issueDetails.project_detail?.identifier}-{issueDetails?.sequence_id}
{project_details?.identifier}-{issueDetails?.sequence_id}
</h6>
<h4 className="break-words text-2xl font-medium">{issueDetails.name}</h4>
{description !== "" && description !== "<p></p>" && (
@ -34,4 +38,4 @@ export const PeekOverviewIssueDetails: React.FC<Props> = (props) => {
<IssueReactions anchor={anchor} />
</div>
);
};
});

View file

@ -32,10 +32,10 @@ export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = 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<IssueEmojiReactionsProps> = 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<IssueEmojiReactionsProps> = observer(
tooltipContent={
<div>
{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"}
</div>
}
@ -92,7 +92,7 @@ export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = 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<IssueEmojiReactionsProps> = observer(
<span>{renderEmoji(reaction)}</span>
<span
className={
reactions?.some((r) => 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"
: ""
}

View file

@ -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<Props> = ({ issueDetails, mode }) => {
const state = issueDetails.state_detail;
export const PeekOverviewIssueProperties: React.FC<Props> = 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<Props> = ({ issueDetails, mod
{mode === "full" && (
<div className="flex justify-between gap-2 pb-3">
<h6 className="flex items-center gap-2 font-medium">
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
{project_details?.identifier}-{issueDetails.sequence_id}
</h6>
<div className="flex items-center gap-2">
<button type="button" onClick={handleCopyLink} className="-rotate-45">
@ -58,7 +67,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mod
<span>State</span>
</div>
<div className="w-3/4 flex items-center gap-1.5 py-0.5 text-sm">
<StateGroupIcon stateGroup={state.group} color={state.color} />
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} />
{addSpaceIfCamelCase(state?.name ?? "")}
</div>
</div>
@ -101,10 +110,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mod
{issueDetails.target_date ? (
<div
className={cn("flex items-center gap-1.5 rounded py-0.5 text-xs text-custom-text-100", {
"text-red-500": shouldHighlightIssueDueDate(
issueDetails.target_date,
issueDetails.state_detail.group
),
"text-red-500": shouldHighlightIssueDueDate(issueDetails.target_date, state?.group),
})}
>
<CalendarCheck2 className="size-3" />
@ -118,4 +124,4 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mod
</div>
</div>
);
};
});

View file

@ -37,20 +37,20 @@ export const IssueVotes: React.FC<TIssueVotes> = 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<TIssueVotes> = 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<TIssueVotes> = 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"}

View file

@ -33,12 +33,12 @@ export const IssuePeekOverview: FC<TIssuePeekOverview> = 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);

View file

@ -75,4 +75,4 @@ export const issuePriorityFilter = (priorityKey: TIssuePriorities): TIssueFilter
if (currentIssuePriority) return currentIssuePriority;
return undefined;
};
};

View file

@ -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";

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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") {

View file

@ -0,0 +1,41 @@
import { RefObject, useEffect } from "react";
export type UseIntersectionObserverProps = {
containerRef: RefObject<HTMLDivElement | null> | undefined;
elementRef: HTMLElement | null;
callback: () => void;
rootMargin?: string;
};
export const useIntersectionObserver = (
containerRef: RefObject<HTMLDivElement | null>,
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]);
};

View file

@ -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<any> {
async getIssueById(anchor: string, issueID: string): Promise<IIssue> {
return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/`)
.then((response) => response?.data)
.catch((error) => {

View file

@ -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<IIssueLabel[]> {
return this.get(`api/public/anchor/${anchor}/labels/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View file

@ -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<IState[]> {
return this.get(`api/public/anchor/${anchor}/states/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View file

@ -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<TIssueGroupByOptions, null> | "target_date";
export enum EIssueGroupedAction {
ADD = "ADD",
DELETE = "DELETE",
REORDER = "REORDER",
}
export interface IBaseIssuesStore {
// observable
loader: Record<string, TLoader>;
issuesMap: Record<string, IIssue>; // 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<IIssue | undefined>;
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<TIssueDisplayFilterOptions, keyof IIssue> = {
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<string, TLoader> = {};
groupedIssueIds: TIssues | undefined = undefined;
issuePaginationData: TIssuePaginationData = {};
issuesMap: Record<string, IIssue> = {}; // 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;
};
}

View file

@ -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<Record<TIssueParams, string | boolean>> | 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<Record<TIssueParams, string | boolean>> = {
...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;
};

View file

@ -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);
});
}
};

View file

@ -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: <K extends keyof TIssueFilters>(
anchor: string,
filterKind: K,
filterKey: keyof TIssueFilters[K],
filters: TIssueFilters[K][typeof filterKey]
) => Promise<void>;
getFilterParams: (
options: IssuePaginationOptions,
anchor: string,
cursor: string | undefined,
groupId: string | undefined,
subGroupId: string | undefined
) => Partial<Record<TIssueParams, string | boolean>>;
}
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 <K extends keyof TIssueFilters>(
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");
};
}

View file

@ -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<void>;
// helpers
getCountOfIssuesByState: (stateID: string) => number;
getFilteredIssuesByState: (stateID: string) => IIssue[];
fetchPublicIssues: (
anchor: string,
loadType: TLoader,
options: IssuePaginationOptions,
isExistingPaginationOptions?: boolean
) => Promise<void>;
fetchNextPublicIssues: (anchor: string, groupId?: string, subGroupId?: string) => Promise<void>;
fetchPublicIssuesWithExistingPagination: (anchor: string, loadType?: TLoader) => Promise<void>;
}
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);
};
}

View file

@ -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<IIssueLabel[]>;
}
export class LabelStore implements IIssueLabelStore {
labelMap: Record<string, IIssueLabel> = {};
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;
};
}

View file

@ -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);
}

View file

@ -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<IState[]>;
}
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;
};
}

View file

@ -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<TFilters>;
export type TIssueQueryFiltersParams = Partial<Record<keyof TFilters, string>>;
export type TIssuesResponse = {
states: IStateLite[];
labels: IIssueLabel[];
issues: IIssue[];
};
export interface IIssue
extends Pick<TIssue, "description_html" | "id" | "name" | "priority" | "sequence_id" | "start_date" | "target_date"> {
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;
}

View file

@ -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 = <T extends { reaction: string }>(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;

View file

@ -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": "*"
}
}
}

View file

@ -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";

View file

@ -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

View file

@ -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";

View file

@ -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,

View file

@ -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";

View file

@ -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",
},
];
];

View file

@ -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";

View file

@ -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";
/**

View file

@ -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";

View file

@ -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