mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
77 Commits
sync/cee
...
feat/pagin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d697c9d78 | ||
|
|
7c9a2b99e6 | ||
|
|
4960e70df6 | ||
|
|
588a096c94 | ||
|
|
9b4176aa17 | ||
|
|
57133122ec | ||
|
|
faec7d98a9 | ||
|
|
e0ec4142ae | ||
|
|
d547ae7896 | ||
|
|
d354ff9a1a | ||
|
|
c3bb16f174 | ||
|
|
3e55490bbd | ||
|
|
eb9dca6d4b | ||
|
|
19fa1c28b9 | ||
|
|
3a07eae192 | ||
|
|
0af1f9e1f0 | ||
|
|
236f0c544a | ||
|
|
834bf27231 | ||
|
|
4341bce9a8 | ||
|
|
61c6cb77e9 | ||
|
|
f760836a90 | ||
|
|
295fdc9386 | ||
|
|
eea1cce926 | ||
|
|
26e8cd1399 | ||
|
|
a9ffd18a2f | ||
|
|
6cdc670bc3 | ||
|
|
24bdcb682b | ||
|
|
6491b40e04 | ||
|
|
3f19326c2c | ||
|
|
5d2e89c285 | ||
|
|
b4416b9172 | ||
|
|
4341f00dd0 | ||
|
|
e3b5b5d65b | ||
|
|
fb4ac91f22 | ||
|
|
aea30897d5 | ||
|
|
33a64fc67b | ||
|
|
b18987bcef | ||
|
|
0d0bfd8a32 | ||
|
|
a28afad8c8 | ||
|
|
b7dcbe91dd | ||
|
|
3bacdd6dd4 | ||
|
|
b8aea19715 | ||
|
|
3f41c260fc | ||
|
|
6ebd215fd6 | ||
|
|
322d434f7e | ||
|
|
ce43067bc1 | ||
|
|
981ac13812 | ||
|
|
d90845b260 | ||
|
|
cd302d061e | ||
|
|
ae339bc19c | ||
|
|
cf470d715a | ||
|
|
3b3f04b7e7 | ||
|
|
0af2da2dfe | ||
|
|
b12a9e3621 | ||
|
|
26ec7323e5 | ||
|
|
8ea0528c7d | ||
|
|
63b850f92f | ||
|
|
026bc9318f | ||
|
|
425b36e391 | ||
|
|
9b84fa060f | ||
|
|
9d60aaddd7 | ||
|
|
75458e33ba | ||
|
|
4899d7df3d | ||
|
|
144d47fdec | ||
|
|
ffeb77ec86 | ||
|
|
a123eea52f | ||
|
|
880bc00666 | ||
|
|
db31644313 | ||
|
|
3929f97167 | ||
|
|
f292ee00a8 | ||
|
|
7060fb712f | ||
|
|
84160e3d8d | ||
|
|
e82d7a2aa8 | ||
|
|
837193cda6 | ||
|
|
32f2719ca0 | ||
|
|
2efa21e8f4 | ||
|
|
0076b09131 |
@@ -79,7 +79,7 @@ class IssueSerializer(BaseSerializer):
|
||||
parsed_str = html.tostring(parsed, encoding="unicode")
|
||||
data["description_html"] = parsed_str
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
raise serializers.ValidationError("Invalid HTML passed")
|
||||
|
||||
# Validate assignees are from project
|
||||
@@ -366,7 +366,7 @@ class IssueCommentSerializer(BaseSerializer):
|
||||
parsed_str = html.tostring(parsed, encoding="unicode")
|
||||
data["comment_html"] = parsed_str
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
raise serializers.ValidationError("Invalid HTML passed")
|
||||
return data
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# Python imports
|
||||
import traceback
|
||||
|
||||
import zoneinfo
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
@@ -101,7 +103,11 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
|
||||
response = super().handle_exception(exc)
|
||||
return response
|
||||
except Exception as e:
|
||||
print(e) if settings.DEBUG else print("Server Error")
|
||||
(
|
||||
print(e, traceback.format_exc())
|
||||
if settings.DEBUG
|
||||
else print("Server Error")
|
||||
)
|
||||
if isinstance(e, IntegrityError):
|
||||
return Response(
|
||||
{"error": "The payload is not valid"},
|
||||
|
||||
@@ -2,42 +2,49 @@
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
OuterRef,
|
||||
Value,
|
||||
UUIDField,
|
||||
)
|
||||
from django.core import serializers
|
||||
from django.db.models import (
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Q,
|
||||
)
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, WebhookMixin
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
IssueSerializer,
|
||||
CycleIssueSerializer,
|
||||
)
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
issue_on_results,
|
||||
issue_queryset_grouper,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import (
|
||||
GroupedOffsetPaginator,
|
||||
SubGroupedOffsetPaginator,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, WebhookMixin
|
||||
|
||||
|
||||
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
@@ -86,14 +93,9 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id, cycle_id):
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
order_by_param = request.GET.get("order_by", "created_at")
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
queryset = (
|
||||
issue_queryset = (
|
||||
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
@@ -105,7 +107,6 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
"issue_module__module",
|
||||
"issue_cycle__cycle",
|
||||
)
|
||||
.order_by(order_by)
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
@@ -130,68 +131,124 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.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())),
|
||||
),
|
||||
)
|
||||
.order_by(order_by)
|
||||
)
|
||||
if self.fields:
|
||||
issues = IssueSerializer(
|
||||
queryset, many=True, fields=fields if fields else None
|
||||
).data
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
issue_queryset = issue_queryset.filter(**filters)
|
||||
# Issue queryset
|
||||
issue_queryset = 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:
|
||||
# Check group and sub group value paginate
|
||||
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:
|
||||
# group and sub group pagination
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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,
|
||||
),
|
||||
)
|
||||
# Group Paginate
|
||||
else:
|
||||
# Group paginate
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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:
|
||||
issues = queryset.values(
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=(
|
||||
"-priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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
|
||||
),
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
def create(self, request, slug, project_id, cycle_id):
|
||||
issues = request.data.get("issues", [])
|
||||
|
||||
@@ -1,52 +1,53 @@
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Q,
|
||||
Case,
|
||||
When,
|
||||
Value,
|
||||
CharField,
|
||||
Count,
|
||||
F,
|
||||
Exists,
|
||||
OuterRef,
|
||||
Subquery,
|
||||
JSONField,
|
||||
Func,
|
||||
Prefetch,
|
||||
IntegerField,
|
||||
)
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import UUIDField
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Count,
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
IntegerField,
|
||||
JSONField,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
UUIDField,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from plane.app.serializers import (
|
||||
DashboardSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueSerializer,
|
||||
WidgetSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Dashboard,
|
||||
DashboardWidget,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
IssueRelation,
|
||||
Project,
|
||||
ProjectMember,
|
||||
User,
|
||||
Widget,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueActivity,
|
||||
ProjectMember,
|
||||
Widget,
|
||||
DashboardWidget,
|
||||
Dashboard,
|
||||
Project,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueRelation,
|
||||
User,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
IssueActivitySerializer,
|
||||
IssueSerializer,
|
||||
DashboardSerializer,
|
||||
WidgetSerializer,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
def dashboard_overview_stats(self, request, slug):
|
||||
@@ -569,6 +570,7 @@ def dashboard_recent_collaborators(self, request, slug):
|
||||
)
|
||||
|
||||
return self.paginate(
|
||||
order_by=request.GET.get("order_by", "-created_at"),
|
||||
request=request,
|
||||
queryset=project_members_with_activities,
|
||||
controller=self.get_results_controller,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import WorkSpaceAdminPermission
|
||||
from plane.app.serializers import ExporterHistorySerializer
|
||||
from plane.bgtasks.export_task import issue_export_task
|
||||
from plane.db.models import ExporterHistory, Project, Workspace
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView
|
||||
from plane.app.permissions import WorkSpaceAdminPermission
|
||||
from plane.bgtasks.export_task import issue_export_task
|
||||
from plane.db.models import Project, ExporterHistory, Workspace
|
||||
|
||||
from plane.app.serializers import ExporterHistorySerializer
|
||||
|
||||
|
||||
class ExportIssuesEndpoint(BaseAPIView):
|
||||
@@ -72,6 +72,7 @@ class ExportIssuesEndpoint(BaseAPIView):
|
||||
"cursor", False
|
||||
):
|
||||
return self.paginate(
|
||||
order_by=request.GET.get("order_by", "-created_at"),
|
||||
request=request,
|
||||
queryset=exporter_history,
|
||||
on_results=lambda exporter_history: ExporterHistorySerializer(
|
||||
|
||||
@@ -1,52 +1,55 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import (
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
)
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Exists,
|
||||
Max,
|
||||
UUIDField,
|
||||
)
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models.functions import Coalesce
|
||||
from rest_framework import status
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
from plane.app.serializers import (
|
||||
IssueSerializer,
|
||||
IssueFlatSerializer,
|
||||
IssueDetailSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
IssueReaction,
|
||||
from plane.app.serializers import (
|
||||
IssueDetailSerializer,
|
||||
IssueFlatSerializer,
|
||||
IssueSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
IssueReaction,
|
||||
IssueSubscriber,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
issue_on_results,
|
||||
issue_queryset_grouper,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import (
|
||||
GroupedOffsetPaginator,
|
||||
SubGroupedOffsetPaginator,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
|
||||
|
||||
class IssueArchiveViewSet(BaseViewSet):
|
||||
@@ -92,33 +95,6 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.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())),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@@ -126,120 +102,128 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
show_sub_issues = request.GET.get("show_sub_issues", "true")
|
||||
|
||||
# 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")
|
||||
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
||||
# 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)
|
||||
|
||||
issue_queryset = (
|
||||
issue_queryset
|
||||
if show_sub_issues == "true"
|
||||
else issue_queryset.filter(parent__isnull=True)
|
||||
)
|
||||
if self.expand or self.fields:
|
||||
issues = IssueSerializer(
|
||||
issue_queryset,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
).data
|
||||
# Issue queryset
|
||||
issue_queryset = 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:
|
||||
# Check group and sub group value paginate
|
||||
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:
|
||||
# group and sub group pagination
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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,
|
||||
),
|
||||
)
|
||||
# Group Paginate
|
||||
else:
|
||||
# Group paginate
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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:
|
||||
issues = issue_queryset.values(
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=(
|
||||
"-priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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
|
||||
),
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
issue = (
|
||||
|
||||
@@ -2,55 +2,50 @@
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Exists,
|
||||
Max,
|
||||
)
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import UUIDField
|
||||
from django.db.models.functions import Coalesce
|
||||
from rest_framework import status
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
from plane.app.serializers import (
|
||||
IssuePropertySerializer,
|
||||
IssueSerializer,
|
||||
IssueCreateSerializer,
|
||||
IssueDetailSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
ProjectLitePermission,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
Issue,
|
||||
IssueProperty,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
IssueReaction,
|
||||
from plane.app.serializers import (
|
||||
IssueCreateSerializer,
|
||||
IssueDetailSerializer,
|
||||
IssuePropertySerializer,
|
||||
IssueSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
IssueProperty,
|
||||
IssueReaction,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
issue_on_results,
|
||||
issue_queryset_grouper,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import (
|
||||
GroupedOffsetPaginator,
|
||||
SubGroupedOffsetPaginator,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView, BaseViewSet, WebhookMixin
|
||||
|
||||
|
||||
class IssueListEndpoint(BaseAPIView):
|
||||
@@ -102,144 +97,125 @@ class IssueListEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.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())),
|
||||
),
|
||||
)
|
||||
).distinct()
|
||||
|
||||
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")
|
||||
|
||||
issue_queryset = queryset.filter(**filters)
|
||||
# Issue queryset
|
||||
issue_queryset = order_issue_queryset(
|
||||
issue_queryset=issue_queryset,
|
||||
order_by_param=order_by_param,
|
||||
)
|
||||
|
||||
# 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")
|
||||
# Group by
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
|
||||
# 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(),
|
||||
# issue queryset
|
||||
issue_queryset = issue_queryset_grouper(
|
||||
queryset=issue_queryset,
|
||||
group_by=group_by,
|
||||
sub_group_by=sub_group_by,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
# Check group and sub group value paginate
|
||||
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:
|
||||
# group and sub group pagination
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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,
|
||||
),
|
||||
)
|
||||
# Group Paginate
|
||||
else:
|
||||
# Group paginate
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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,
|
||||
),
|
||||
)
|
||||
).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:
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=(
|
||||
"-priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else order_by_param
|
||||
)
|
||||
).order_by(
|
||||
"-max_values"
|
||||
if order_by_param.startswith("-")
|
||||
else "max_values"
|
||||
),
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
if self.fields or self.expand:
|
||||
issues = IssueSerializer(
|
||||
queryset, many=True, fields=self.fields, expand=self.expand
|
||||
).data
|
||||
else:
|
||||
issues = issue_queryset.values(
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
@@ -297,33 +273,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.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())),
|
||||
),
|
||||
)
|
||||
).distinct()
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@@ -333,112 +282,116 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
state_order = [
|
||||
"backlog",
|
||||
"unstarted",
|
||||
"started",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
# 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")
|
||||
# Issue queryset
|
||||
issue_queryset = order_issue_queryset(
|
||||
issue_queryset=issue_queryset,
|
||||
order_by_param=order_by_param,
|
||||
)
|
||||
|
||||
# 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(),
|
||||
# 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=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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,
|
||||
),
|
||||
)
|
||||
).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:
|
||||
return self.paginate(
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else order_by_param
|
||||
)
|
||||
).order_by(
|
||||
"-max_values"
|
||||
if order_by_param.startswith("-")
|
||||
else "max_values"
|
||||
),
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
# Only use serializer when expand or fields else return by values
|
||||
if self.expand or self.fields:
|
||||
issues = IssueSerializer(
|
||||
issue_queryset,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
).data
|
||||
else:
|
||||
issues = issue_queryset.values(
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
project = Project.objects.get(pk=project_id)
|
||||
@@ -470,8 +423,13 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
issue = (
|
||||
self.get_queryset()
|
||||
.filter(pk=serializer.data["id"])
|
||||
issue_queryset_grouper(
|
||||
queryset=self.get_queryset().filter(
|
||||
pk=serializer.data["id"]
|
||||
),
|
||||
group_by=None,
|
||||
sub_group_by=None,
|
||||
)
|
||||
.values(
|
||||
"id",
|
||||
"name",
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
Max,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
UUIDField,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
@@ -45,7 +36,17 @@ from plane.db.models import (
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
issue_on_results,
|
||||
issue_queryset_grouper,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import (
|
||||
GroupedOffsetPaginator,
|
||||
SubGroupedOffsetPaginator,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
@@ -88,150 +89,128 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.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())),
|
||||
),
|
||||
)
|
||||
).distinct()
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id):
|
||||
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")
|
||||
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
# Issue queryset
|
||||
issue_queryset = order_issue_queryset(
|
||||
issue_queryset=issue_queryset,
|
||||
order_by_param=order_by_param,
|
||||
)
|
||||
|
||||
# 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")
|
||||
# Group by
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
|
||||
# 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(),
|
||||
# issue queryset
|
||||
issue_queryset = issue_queryset_grouper(
|
||||
queryset=issue_queryset,
|
||||
group_by=group_by,
|
||||
sub_group_by=sub_group_by,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
# Check group and sub group value paginate
|
||||
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:
|
||||
# group and sub group pagination
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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,
|
||||
),
|
||||
)
|
||||
# Group Paginate
|
||||
else:
|
||||
# Group paginate
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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,
|
||||
),
|
||||
)
|
||||
).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:
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=(
|
||||
"-priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else order_by_param
|
||||
)
|
||||
).order_by(
|
||||
"-max_values"
|
||||
if order_by_param.startswith("-")
|
||||
else "max_values"
|
||||
),
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
# Only use serializer when expand else return by values
|
||||
if self.expand or self.fields:
|
||||
issues = IssueSerializer(
|
||||
issue_queryset,
|
||||
many=True,
|
||||
fields=self.fields,
|
||||
expand=self.expand,
|
||||
).data
|
||||
else:
|
||||
issues = issue_queryset.values(
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
project = Project.objects.get(pk=project_id)
|
||||
@@ -262,12 +241,45 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
issue = (
|
||||
self.get_queryset().filter(pk=serializer.data["id"]).first()
|
||||
)
|
||||
return Response(
|
||||
IssueSerializer(issue).data, status=status.HTTP_201_CREATED
|
||||
issue_queryset_grouper(
|
||||
queryset=self.get_queryset().filter(
|
||||
pk=serializer.data["id"]
|
||||
),
|
||||
group_by=None,
|
||||
sub_group_by=None,
|
||||
)
|
||||
.values(
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return Response(issue, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
|
||||
@@ -20,9 +20,9 @@ from django.db.models.functions import Coalesce
|
||||
|
||||
# Django Imports
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import (
|
||||
|
||||
@@ -1,36 +1,50 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
from django.db.models import (
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Q,
|
||||
)
|
||||
|
||||
# Django Imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import F, OuterRef, Func, Q
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Value, UUIDField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
ModuleIssueSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
ModuleIssue,
|
||||
Project,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
issue_on_results,
|
||||
issue_queryset_grouper,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import (
|
||||
GroupedOffsetPaginator,
|
||||
SubGroupedOffsetPaginator,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, WebhookMixin
|
||||
from plane.app.serializers import (
|
||||
ModuleIssueSerializer,
|
||||
IssueSerializer,
|
||||
)
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import (
|
||||
ModuleIssue,
|
||||
Project,
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
@@ -80,77 +94,127 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.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())),
|
||||
),
|
||||
)
|
||||
).distinct()
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id, module_id):
|
||||
fields = [
|
||||
field
|
||||
for field in request.GET.get("fields", "").split(",")
|
||||
if field
|
||||
]
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
if self.fields or self.expand:
|
||||
issues = IssueSerializer(
|
||||
issue_queryset, many=True, fields=fields if fields else None
|
||||
).data
|
||||
order_by_param = request.GET.get("order_by", "created_at")
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset = 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:
|
||||
# Check group and sub group value paginate
|
||||
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:
|
||||
# group and sub group pagination
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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,
|
||||
),
|
||||
)
|
||||
# Group Paginate
|
||||
else:
|
||||
# Group paginate
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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:
|
||||
issues = issue_queryset.values(
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=(
|
||||
"-priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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
|
||||
),
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
# create multiple issues inside a module
|
||||
def create_module_issues(self, request, slug, project_id, module_id):
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
# Django imports
|
||||
from django.db.models import Q, OuterRef, Exists
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
# Module imports
|
||||
from ..base import BaseViewSet, BaseAPIView
|
||||
from plane.db.models import (
|
||||
Notification,
|
||||
IssueAssignee,
|
||||
IssueSubscriber,
|
||||
Issue,
|
||||
WorkspaceMember,
|
||||
UserNotificationPreference,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
NotificationSerializer,
|
||||
UserNotificationPreferenceSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAssignee,
|
||||
IssueSubscriber,
|
||||
Notification,
|
||||
UserNotificationPreference,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
# Module imports
|
||||
from ..base import BaseAPIView, BaseViewSet
|
||||
|
||||
|
||||
class NotificationViewSet(BaseViewSet, BasePaginator):
|
||||
@@ -131,6 +132,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
||||
"cursor", False
|
||||
):
|
||||
return self.paginate(
|
||||
order_by=request.GET.get("order_by", "-created_at"),
|
||||
request=request,
|
||||
queryset=(notifications),
|
||||
on_results=lambda notifications: NotificationSerializer(
|
||||
|
||||
@@ -1,53 +1,51 @@
|
||||
# Python imports
|
||||
import boto3
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
Q,
|
||||
Exists,
|
||||
OuterRef,
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework import serializers
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseViewSet, BaseAPIView, WebhookMixin
|
||||
from plane.app.serializers import (
|
||||
ProjectSerializer,
|
||||
ProjectListSerializer,
|
||||
ProjectFavoriteSerializer,
|
||||
ProjectDeployBoardSerializer,
|
||||
)
|
||||
|
||||
from plane.app.permissions import (
|
||||
ProjectBasePermission,
|
||||
ProjectMemberPermission,
|
||||
)
|
||||
|
||||
from plane.app.serializers import (
|
||||
ProjectDeployBoardSerializer,
|
||||
ProjectFavoriteSerializer,
|
||||
ProjectListSerializer,
|
||||
ProjectSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView, BaseViewSet, WebhookMixin
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
Workspace,
|
||||
State,
|
||||
ProjectFavorite,
|
||||
ProjectIdentifier,
|
||||
Module,
|
||||
Cycle,
|
||||
Inbox,
|
||||
ProjectDeployBoard,
|
||||
IssueProperty,
|
||||
Issue,
|
||||
IssueProperty,
|
||||
Module,
|
||||
Project,
|
||||
ProjectDeployBoard,
|
||||
ProjectFavorite,
|
||||
ProjectIdentifier,
|
||||
ProjectMember,
|
||||
State,
|
||||
Workspace,
|
||||
)
|
||||
from plane.utils.cache import cache_response
|
||||
|
||||
@@ -166,6 +164,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
"cursor", False
|
||||
):
|
||||
return self.paginate(
|
||||
order_by=request.GET.get("order_by", "-created_at"),
|
||||
request=request,
|
||||
queryset=(projects),
|
||||
on_results=lambda projects: ProjectListSerializer(
|
||||
|
||||
@@ -183,6 +183,7 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
|
||||
).select_related("actor", "workspace", "issue", "project")
|
||||
|
||||
return self.paginate(
|
||||
order_by=request.GET.get("order_by", "-created_at"),
|
||||
request=request,
|
||||
queryset=queryset,
|
||||
on_results=lambda issue_activities: IssueActivitySerializer(
|
||||
|
||||
@@ -1,47 +1,53 @@
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Q,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Exists,
|
||||
Max,
|
||||
)
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import UUIDField
|
||||
from django.db.models import (
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Q,
|
||||
UUIDField,
|
||||
Value,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from rest_framework import status
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from plane.app.permissions import (
|
||||
ProjectEntityPermission,
|
||||
WorkspaceEntityPermission,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
IssueViewFavoriteSerializer,
|
||||
IssueViewSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
IssueView,
|
||||
IssueViewFavorite,
|
||||
Workspace,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
issue_on_results,
|
||||
issue_queryset_grouper,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import (
|
||||
GroupedOffsetPaginator,
|
||||
SubGroupedOffsetPaginator,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
from plane.app.serializers import (
|
||||
IssueViewSerializer,
|
||||
IssueSerializer,
|
||||
IssueViewFavoriteSerializer,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
ProjectEntityPermission,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
IssueView,
|
||||
Issue,
|
||||
IssueViewFavorite,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class GlobalViewViewSet(BaseViewSet):
|
||||
@@ -144,17 +150,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug):
|
||||
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")
|
||||
|
||||
issue_queryset = (
|
||||
@@ -163,99 +158,119 @@ class GlobalViewIssuesViewSet(BaseViewSet):
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
)
|
||||
|
||||
# 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")
|
||||
# Issue queryset
|
||||
issue_queryset = order_issue_queryset(
|
||||
issue_queryset=issue_queryset,
|
||||
order_by_param=order_by_param,
|
||||
)
|
||||
|
||||
# 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(),
|
||||
# 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:
|
||||
# Check group and sub group value paginate
|
||||
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:
|
||||
# group and sub group pagination
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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=None,
|
||||
filters=filters,
|
||||
),
|
||||
sub_group_by_fields=issue_group_values(
|
||||
field=sub_group_by,
|
||||
slug=slug,
|
||||
project_id=None,
|
||||
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,
|
||||
),
|
||||
)
|
||||
# Group Paginate
|
||||
else:
|
||||
# Group paginate
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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=None,
|
||||
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,
|
||||
),
|
||||
)
|
||||
).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:
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=(
|
||||
"-priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else order_by_param
|
||||
)
|
||||
).order_by(
|
||||
"-max_values"
|
||||
if order_by_param.startswith("-")
|
||||
else "max_values"
|
||||
),
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
if self.fields:
|
||||
issues = IssueSerializer(
|
||||
issue_queryset, many=True, fields=self.fields
|
||||
).data
|
||||
else:
|
||||
issues = issue_queryset.values(
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueViewViewSet(BaseViewSet):
|
||||
|
||||
@@ -1,60 +1,61 @@
|
||||
# Python imports
|
||||
from datetime import date
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Count,
|
||||
F,
|
||||
Func,
|
||||
IntegerField,
|
||||
Max,
|
||||
OuterRef,
|
||||
Q,
|
||||
UUIDField,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.fields import DateField
|
||||
from django.db.models.functions import Cast, Coalesce, ExtractWeek
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import (
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Count,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Max,
|
||||
IntegerField,
|
||||
UUIDField,
|
||||
)
|
||||
from django.db.models.functions import ExtractWeek, Cast
|
||||
from django.db.models.fields import DateField
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
WorkspaceViewerPermission,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
WorkSpaceSerializer,
|
||||
ProjectMemberSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueSerializer,
|
||||
ProjectMemberSerializer,
|
||||
WorkSpaceSerializer,
|
||||
WorkspaceUserPropertiesSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
ProjectMember,
|
||||
IssueActivity,
|
||||
CycleIssue,
|
||||
Issue,
|
||||
IssueLink,
|
||||
IssueActivity,
|
||||
IssueAttachment,
|
||||
IssueLink,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
ProjectMember,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
CycleIssue,
|
||||
WorkspaceUserProperties,
|
||||
)
|
||||
from plane.app.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
WorkspaceViewerPermission,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
@@ -397,6 +398,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
|
||||
queryset = queryset.filter(project__in=projects)
|
||||
|
||||
return self.paginate(
|
||||
order_by=request.GET.get("order_by", "-created_at"),
|
||||
request=request,
|
||||
queryset=queryset,
|
||||
on_results=lambda issue_activities: IssueActivitySerializer(
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
@@ -1,56 +1,57 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import (
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Q,
|
||||
)
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Q,
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Exists,
|
||||
Max,
|
||||
IntegerField,
|
||||
)
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.app.serializers import (
|
||||
IssueCommentSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
IssueVoteSerializer,
|
||||
IssuePublicSerializer,
|
||||
)
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueComment,
|
||||
Label,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
State,
|
||||
ProjectMember,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
ProjectDeployBoard,
|
||||
IssueVote,
|
||||
ProjectPublicMember,
|
||||
from plane.app.serializers import (
|
||||
CommentReactionSerializer,
|
||||
IssueCommentSerializer,
|
||||
IssuePublicSerializer,
|
||||
IssueReactionSerializer,
|
||||
IssueVoteSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
from plane.db.models import (
|
||||
CommentReaction,
|
||||
Issue,
|
||||
IssueAttachment,
|
||||
IssueComment,
|
||||
IssueLink,
|
||||
IssueReaction,
|
||||
IssueVote,
|
||||
ProjectDeployBoard,
|
||||
ProjectMember,
|
||||
ProjectPublicMember,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
issue_on_results,
|
||||
issue_queryset_grouper,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import (
|
||||
GroupedOffsetPaginator,
|
||||
SubGroupedOffsetPaginator,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView, BaseViewSet
|
||||
|
||||
|
||||
class IssueCommentPublicViewSet(BaseViewSet):
|
||||
@@ -523,46 +524,15 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
)
|
||||
.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"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"votes",
|
||||
queryset=IssueVote.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
.filter(**filters)
|
||||
Issue.objects.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(is_draft=True)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -577,112 +547,130 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(
|
||||
parent=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
).distinct()
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset = order_issue_queryset(
|
||||
issue_queryset=issue_queryset,
|
||||
order_by_param=order_by_param,
|
||||
)
|
||||
|
||||
# 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")
|
||||
# Group by
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
|
||||
# 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(),
|
||||
# issue queryset
|
||||
issue_queryset = issue_queryset_grouper(
|
||||
queryset=issue_queryset,
|
||||
group_by=group_by,
|
||||
sub_group_by=sub_group_by,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
# Check group and sub group value paginate
|
||||
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:
|
||||
# group and sub group pagination
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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,
|
||||
),
|
||||
)
|
||||
# Group Paginate
|
||||
else:
|
||||
# Group paginate
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=(
|
||||
"priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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,
|
||||
),
|
||||
)
|
||||
).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(),
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=(
|
||||
"-priority_order"
|
||||
if order_by_param in ["priority", "-priority"]
|
||||
else 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
|
||||
),
|
||||
)
|
||||
.values("name", "group", "color", "id")
|
||||
.order_by("custom_order", "sequence")
|
||||
)
|
||||
|
||||
labels = Label.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).values("id", "name", "color", "parent")
|
||||
|
||||
## Grouping the results
|
||||
group_by = request.GET.get("group_by", False)
|
||||
if group_by:
|
||||
issues = group_results(issues, group_by)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"issues": issues,
|
||||
"states": states,
|
||||
"labels": labels,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -1,240 +1,191 @@
|
||||
def resolve_keys(group_keys, value):
|
||||
"""resolve keys to a key which will be used for
|
||||
grouping
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Q, UUIDField, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
Args:
|
||||
group_keys (string): key which will be used for grouping
|
||||
value (obj): data value
|
||||
|
||||
Returns:
|
||||
string: the key which will be used for
|
||||
"""
|
||||
keys = group_keys.split(".")
|
||||
for key in keys:
|
||||
value = value.get(key, None)
|
||||
return value
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
Issue,
|
||||
Label,
|
||||
Module,
|
||||
Project,
|
||||
ProjectMember,
|
||||
State,
|
||||
WorkspaceMember,
|
||||
)
|
||||
|
||||
|
||||
def group_results(results_data, group_by, sub_group_by=False):
|
||||
"""group results data into certain group_by
|
||||
def issue_queryset_grouper(queryset, group_by, sub_group_by):
|
||||
|
||||
Args:
|
||||
results_data (obj): complete results data
|
||||
group_by (key): string
|
||||
FIELD_MAPPER = {
|
||||
"label_ids": "labels__id",
|
||||
"assignee_ids": "assignees__id",
|
||||
"module_ids": "modules__id",
|
||||
}
|
||||
|
||||
Returns:
|
||||
obj: grouped results
|
||||
"""
|
||||
if sub_group_by:
|
||||
main_responsive_dict = dict()
|
||||
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
|
||||
}
|
||||
|
||||
if sub_group_by == "priority":
|
||||
main_responsive_dict = {
|
||||
"urgent": {},
|
||||
"high": {},
|
||||
"medium": {},
|
||||
"low": {},
|
||||
"none": {},
|
||||
}
|
||||
return queryset.annotate(**default_annotations)
|
||||
|
||||
for value in results_data:
|
||||
main_group_attribute = resolve_keys(sub_group_by, value)
|
||||
group_attribute = resolve_keys(group_by, value)
|
||||
if isinstance(main_group_attribute, list) and not isinstance(
|
||||
group_attribute, list
|
||||
):
|
||||
if len(main_group_attribute):
|
||||
for attrib in main_group_attribute:
|
||||
if str(attrib) not in main_responsive_dict:
|
||||
main_responsive_dict[str(attrib)] = {}
|
||||
if (
|
||||
str(group_attribute)
|
||||
in main_responsive_dict[str(attrib)]
|
||||
):
|
||||
main_responsive_dict[str(attrib)][
|
||||
str(group_attribute)
|
||||
].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(attrib)][
|
||||
str(group_attribute)
|
||||
] = []
|
||||
main_responsive_dict[str(attrib)][
|
||||
str(group_attribute)
|
||||
].append(value)
|
||||
else:
|
||||
if str(None) not in main_responsive_dict:
|
||||
main_responsive_dict[str(None)] = {}
|
||||
|
||||
if str(group_attribute) in main_responsive_dict[str(None)]:
|
||||
main_responsive_dict[str(None)][
|
||||
str(group_attribute)
|
||||
].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(None)][
|
||||
str(group_attribute)
|
||||
] = []
|
||||
main_responsive_dict[str(None)][
|
||||
str(group_attribute)
|
||||
].append(value)
|
||||
def issue_on_results(issues, group_by, sub_group_by):
|
||||
|
||||
elif isinstance(group_attribute, list) and not isinstance(
|
||||
main_group_attribute, list
|
||||
):
|
||||
if str(main_group_attribute) not in main_responsive_dict:
|
||||
main_responsive_dict[str(main_group_attribute)] = {}
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
if (
|
||||
str(attrib)
|
||||
in main_responsive_dict[str(main_group_attribute)]
|
||||
):
|
||||
main_responsive_dict[str(main_group_attribute)][
|
||||
str(attrib)
|
||||
].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_group_attribute)][
|
||||
str(attrib)
|
||||
] = []
|
||||
main_responsive_dict[str(main_group_attribute)][
|
||||
str(attrib)
|
||||
].append(value)
|
||||
else:
|
||||
if (
|
||||
str(None)
|
||||
in main_responsive_dict[str(main_group_attribute)]
|
||||
):
|
||||
main_responsive_dict[str(main_group_attribute)][
|
||||
str(None)
|
||||
].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_group_attribute)][
|
||||
str(None)
|
||||
] = []
|
||||
main_responsive_dict[str(main_group_attribute)][
|
||||
str(None)
|
||||
].append(value)
|
||||
FIELD_MAPPER = {
|
||||
"labels__id": "label_ids",
|
||||
"assignees__id": "assignee_ids",
|
||||
"modules__id": "module_ids",
|
||||
}
|
||||
|
||||
elif isinstance(group_attribute, list) and isinstance(
|
||||
main_group_attribute, list
|
||||
):
|
||||
if len(main_group_attribute):
|
||||
for main_attrib in main_group_attribute:
|
||||
if str(main_attrib) not in main_responsive_dict:
|
||||
main_responsive_dict[str(main_attrib)] = {}
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
if (
|
||||
str(attrib)
|
||||
in main_responsive_dict[str(main_attrib)]
|
||||
):
|
||||
main_responsive_dict[str(main_attrib)][
|
||||
str(attrib)
|
||||
].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_attrib)][
|
||||
str(attrib)
|
||||
] = []
|
||||
main_responsive_dict[str(main_attrib)][
|
||||
str(attrib)
|
||||
].append(value)
|
||||
else:
|
||||
if (
|
||||
str(None)
|
||||
in main_responsive_dict[str(main_attrib)]
|
||||
):
|
||||
main_responsive_dict[str(main_attrib)][
|
||||
str(None)
|
||||
].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_attrib)][
|
||||
str(None)
|
||||
] = []
|
||||
main_responsive_dict[str(main_attrib)][
|
||||
str(None)
|
||||
].append(value)
|
||||
else:
|
||||
if str(None) not in main_responsive_dict:
|
||||
main_responsive_dict[str(None)] = {}
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
if str(attrib) in main_responsive_dict[str(None)]:
|
||||
main_responsive_dict[str(None)][
|
||||
str(attrib)
|
||||
].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(None)][
|
||||
str(attrib)
|
||||
] = []
|
||||
main_responsive_dict[str(None)][
|
||||
str(attrib)
|
||||
].append(value)
|
||||
else:
|
||||
if str(None) in main_responsive_dict[str(None)]:
|
||||
main_responsive_dict[str(None)][str(None)].append(
|
||||
value
|
||||
)
|
||||
else:
|
||||
main_responsive_dict[str(None)][str(None)] = []
|
||||
main_responsive_dict[str(None)][str(None)].append(
|
||||
value
|
||||
)
|
||||
else:
|
||||
main_group_attribute = resolve_keys(sub_group_by, value)
|
||||
group_attribute = resolve_keys(group_by, value)
|
||||
original_list = ["assignee_ids", "label_ids", "module_ids"]
|
||||
|
||||
if str(main_group_attribute) not in main_responsive_dict:
|
||||
main_responsive_dict[str(main_group_attribute)] = {}
|
||||
required_fields = [
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"parent_id",
|
||||
"cycle_id",
|
||||
"sub_issues_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"attachment_count",
|
||||
"link_count",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
"state__group",
|
||||
]
|
||||
|
||||
if (
|
||||
str(group_attribute)
|
||||
in main_responsive_dict[str(main_group_attribute)]
|
||||
):
|
||||
main_responsive_dict[str(main_group_attribute)][
|
||||
str(group_attribute)
|
||||
].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_group_attribute)][
|
||||
str(group_attribute)
|
||||
] = []
|
||||
main_responsive_dict[str(main_group_attribute)][
|
||||
str(group_attribute)
|
||||
].append(value)
|
||||
if group_by in FIELD_MAPPER:
|
||||
original_list.remove(FIELD_MAPPER[group_by])
|
||||
original_list.append(group_by)
|
||||
|
||||
return main_responsive_dict
|
||||
if sub_group_by in FIELD_MAPPER:
|
||||
original_list.remove(FIELD_MAPPER[sub_group_by])
|
||||
original_list.append(sub_group_by)
|
||||
|
||||
else:
|
||||
response_dict = {}
|
||||
required_fields.extend(original_list)
|
||||
return issues.values(*required_fields)
|
||||
|
||||
if group_by == "priority":
|
||||
response_dict = {
|
||||
"urgent": [],
|
||||
"high": [],
|
||||
"medium": [],
|
||||
"low": [],
|
||||
"none": [],
|
||||
}
|
||||
|
||||
for value in results_data:
|
||||
group_attribute = resolve_keys(group_by, value)
|
||||
if isinstance(group_attribute, list):
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
if str(attrib) in response_dict:
|
||||
response_dict[str(attrib)].append(value)
|
||||
else:
|
||||
response_dict[str(attrib)] = []
|
||||
response_dict[str(attrib)].append(value)
|
||||
else:
|
||||
if str(None) in response_dict:
|
||||
response_dict[str(None)].append(value)
|
||||
else:
|
||||
response_dict[str(None)] = []
|
||||
response_dict[str(None)].append(value)
|
||||
else:
|
||||
if str(group_attribute) in response_dict:
|
||||
response_dict[str(group_attribute)].append(value)
|
||||
else:
|
||||
response_dict[str(group_attribute)] = []
|
||||
response_dict[str(group_attribute)].append(value)
|
||||
|
||||
return response_dict
|
||||
def issue_group_values(field, slug, project_id=None, filters={}):
|
||||
if field == "state_id":
|
||||
queryset = State.objects.filter(
|
||||
~Q(name="Triage"),
|
||||
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 == "modules__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)
|
||||
return []
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import re
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
# The date from pattern
|
||||
@@ -63,24 +64,27 @@ def date_filter(filter, date_term, queries):
|
||||
"""
|
||||
for query in queries:
|
||||
date_query = query.split(";")
|
||||
if len(date_query) >= 2:
|
||||
match = pattern.match(date_query[0])
|
||||
if match:
|
||||
if len(date_query) == 3:
|
||||
digit, term = date_query[0].split("_")
|
||||
string_date_filter(
|
||||
filter=filter,
|
||||
duration=int(digit),
|
||||
subsequent=date_query[1],
|
||||
term=term,
|
||||
date_filter=date_term,
|
||||
offset=date_query[2],
|
||||
)
|
||||
else:
|
||||
if "after" in date_query:
|
||||
filter[f"{date_term}__gte"] = date_query[0]
|
||||
if date_query:
|
||||
if len(date_query) >= 2:
|
||||
match = pattern.match(date_query[0])
|
||||
if match:
|
||||
if len(date_query) == 3:
|
||||
digit, term = date_query[0].split("_")
|
||||
string_date_filter(
|
||||
filter=filter,
|
||||
duration=int(digit),
|
||||
subsequent=date_query[1],
|
||||
term=term,
|
||||
date_filter=date_term,
|
||||
offset=date_query[2],
|
||||
)
|
||||
else:
|
||||
filter[f"{date_term}__lte"] = date_query[0]
|
||||
if "after" in date_query:
|
||||
filter[f"{date_term}__gte"] = date_query[0]
|
||||
else:
|
||||
filter[f"{date_term}__lte"] = date_query[0]
|
||||
else:
|
||||
filter[f"{date_term}__contains"] = date_query[0]
|
||||
|
||||
|
||||
def filter_state(params, filter, method):
|
||||
|
||||
79
apiserver/plane/utils/order_queryset.py
Normal file
79
apiserver/plane/utils/order_queryset.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from django.db.models import (
|
||||
Case,
|
||||
Value,
|
||||
CharField,
|
||||
When,
|
||||
Max,
|
||||
)
|
||||
|
||||
# Custom ordering for priority and state
|
||||
PRIORITY_ORDER = ["urgent", "high", "medium", "low", "none"]
|
||||
STATE_ORDER = [
|
||||
"backlog",
|
||||
"unstarted",
|
||||
"started",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
|
||||
def order_issue_queryset(issue_queryset, order_by_param="created_at"):
|
||||
# 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)
|
||||
|
||||
return issue_queryset
|
||||
@@ -1,7 +1,17 @@
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import ParseError
|
||||
from collections.abc import Sequence
|
||||
# Python imports
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Count, F, Window
|
||||
from django.db.models.functions import RowNumber
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.exceptions import ParseError
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
|
||||
|
||||
class Cursor:
|
||||
@@ -103,6 +113,80 @@ class OffsetPaginator:
|
||||
|
||||
limit = min(limit, self.max_limit)
|
||||
|
||||
queryset = self.queryset
|
||||
if self.key:
|
||||
queryset = queryset.order_by(
|
||||
*self.key,
|
||||
"-created_at",
|
||||
)
|
||||
|
||||
page = cursor.offset
|
||||
offset = cursor.offset * cursor.value
|
||||
stop = offset + (cursor.value or limit) + 1
|
||||
|
||||
if self.max_offset is not None and offset >= self.max_offset:
|
||||
raise BadPaginationError("Pagination offset too large")
|
||||
if offset < 0:
|
||||
raise BadPaginationError("Pagination offset cannot be negative")
|
||||
|
||||
results = queryset[offset:stop]
|
||||
|
||||
if cursor.value != limit:
|
||||
results = results[-(limit + 1) :]
|
||||
|
||||
next_cursor = Cursor(limit, page + 1, False, results.count() > limit)
|
||||
prev_cursor = Cursor(limit, page - 1, True, page > 0)
|
||||
|
||||
results = results[:limit]
|
||||
if self.on_results:
|
||||
results = self.on_results(results)
|
||||
|
||||
count = queryset.count()
|
||||
max_hits = math.ceil(count / limit)
|
||||
|
||||
return CursorResult(
|
||||
results=results,
|
||||
next=next_cursor,
|
||||
prev=prev_cursor,
|
||||
hits=count,
|
||||
max_hits=max_hits,
|
||||
)
|
||||
|
||||
def process_results(self, results):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class GroupedOffsetPaginator(OffsetPaginator):
|
||||
|
||||
FIELD_MAPPER = {
|
||||
"labels__id": "label_ids",
|
||||
"assignees__id": "assignee_ids",
|
||||
"modules__id": "module_ids",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
queryset,
|
||||
group_by_field_name,
|
||||
group_by_fields,
|
||||
count_filter,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(queryset, *args, **kwargs)
|
||||
self.group_by_field_name = group_by_field_name
|
||||
self.group_by_fields = group_by_fields
|
||||
self.count_filter = count_filter
|
||||
|
||||
def get_result(self, limit=50, cursor=None):
|
||||
# offset is page #
|
||||
# value is page limit
|
||||
if cursor is None:
|
||||
cursor = Cursor(0, 0, 0)
|
||||
|
||||
limit = min(limit, self.max_limit)
|
||||
|
||||
# Adjust the initial offset and stop based on the cursor and limit
|
||||
queryset = self.queryset
|
||||
if self.key:
|
||||
queryset = queryset.order_by(*self.key)
|
||||
@@ -116,28 +200,414 @@ class OffsetPaginator:
|
||||
if offset < 0:
|
||||
raise BadPaginationError("Pagination offset cannot be negative")
|
||||
|
||||
results = list(queryset[offset:stop])
|
||||
if cursor.value != limit:
|
||||
results = results[-(limit + 1) :]
|
||||
# Compute the results
|
||||
results = {}
|
||||
queryset = queryset.annotate(
|
||||
row_number=Window(
|
||||
expression=RowNumber(),
|
||||
partition_by=[F(self.group_by_field_name)],
|
||||
order_by=(
|
||||
*self.key,
|
||||
"-created_at",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
next_cursor = Cursor(limit, page + 1, False, len(results) > limit)
|
||||
prev_cursor = Cursor(limit, page - 1, True, page > 0)
|
||||
# Filter the results
|
||||
results = queryset.filter(row_number__gt=offset, row_number__lt=stop)
|
||||
|
||||
results = list(results[:limit])
|
||||
if self.on_results:
|
||||
results = self.on_results(results)
|
||||
# Adjust cursors based on the grouped results for pagination
|
||||
next_cursor = Cursor(
|
||||
limit,
|
||||
page + 1,
|
||||
False,
|
||||
queryset.filter(row_number__gte=stop).exists(),
|
||||
)
|
||||
prev_cursor = Cursor(
|
||||
limit,
|
||||
page - 1,
|
||||
True,
|
||||
page > 0,
|
||||
)
|
||||
|
||||
count = queryset.count()
|
||||
max_hits = math.ceil(count / limit)
|
||||
|
||||
# Optionally, calculate the total count and max_hits if needed
|
||||
# This might require adjustments based on specific use cases
|
||||
max_hits = math.ceil(
|
||||
queryset.values(self.group_by_field_name)
|
||||
.annotate(
|
||||
count=Count(
|
||||
"id",
|
||||
filter=self.count_filter,
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
.order_by("-count")[0]["count"]
|
||||
/ limit
|
||||
)
|
||||
return CursorResult(
|
||||
results=results,
|
||||
next=next_cursor,
|
||||
prev=prev_cursor,
|
||||
hits=None,
|
||||
hits=count,
|
||||
max_hits=max_hits,
|
||||
)
|
||||
|
||||
def __get_total_queryset(self):
|
||||
return (
|
||||
self.queryset.values(self.group_by_field_name)
|
||||
.annotate(
|
||||
count=Count(
|
||||
"id",
|
||||
filter=self.count_filter,
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
.order_by()
|
||||
)
|
||||
|
||||
def __get_total_dict(self):
|
||||
total_group_dict = {}
|
||||
for group in self.__get_total_queryset():
|
||||
total_group_dict[str(group.get(self.group_by_field_name))] = (
|
||||
total_group_dict.get(
|
||||
str(group.get(self.group_by_field_name)), 0
|
||||
)
|
||||
+ (1 if group.get("count") == 0 else group.get("count"))
|
||||
)
|
||||
|
||||
return total_group_dict
|
||||
|
||||
def __get_field_dict(self):
|
||||
total_group_dict = self.__get_total_dict()
|
||||
return {
|
||||
str(field): {
|
||||
"results": [],
|
||||
"total_results": total_group_dict.get(str(field), 0),
|
||||
}
|
||||
for field in self.group_by_fields
|
||||
}
|
||||
|
||||
def __result_already_added(self, result, group):
|
||||
for existing_issue in group:
|
||||
if existing_issue["id"] == result["id"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def __query_multi_grouper(self, results):
|
||||
|
||||
total_group_dict = self.__get_total_dict()
|
||||
|
||||
# Preparing a dict to keep track of group IDs associated with each label ID
|
||||
result_group_mapping = defaultdict(set)
|
||||
# Preparing a dict to group result by group ID
|
||||
grouped_by_field_name = defaultdict(list)
|
||||
|
||||
# Iterate over results to fill the above dictionaries
|
||||
for result in results:
|
||||
result_id = result["id"]
|
||||
group_id = result[self.group_by_field_name]
|
||||
result_group_mapping[str(result_id)].add(str(group_id))
|
||||
|
||||
# Adding group_ids key to each issue and grouping by group_name
|
||||
for result in results:
|
||||
result_id = result["id"]
|
||||
group_ids = list(result_group_mapping[str(result_id)])
|
||||
result[self.FIELD_MAPPER.get(self.group_by_field_name)] = (
|
||||
[] if "None" in group_ids else group_ids
|
||||
)
|
||||
# If a result belongs to multiple groups, add it to each group
|
||||
for group_id in group_ids:
|
||||
if not self.__result_already_added(
|
||||
result, grouped_by_field_name[group_id]
|
||||
):
|
||||
grouped_by_field_name[group_id].append(result)
|
||||
|
||||
# Convert grouped_by_field_name back to a list for each group
|
||||
processed_results = {
|
||||
str(group_id): {
|
||||
"results": issues,
|
||||
"total_results": total_group_dict.get(str(group_id)),
|
||||
}
|
||||
for group_id, issues in grouped_by_field_name.items()
|
||||
}
|
||||
|
||||
# ordering
|
||||
for group_value, data in processed_results.items():
|
||||
data["results"].sort(
|
||||
key=lambda x: x.get("created_at"), reverse=True
|
||||
)
|
||||
return processed_results
|
||||
|
||||
def __query_grouper(self, results):
|
||||
processed_results = self.__get_field_dict()
|
||||
for result in results:
|
||||
group_value = str(result.get(self.group_by_field_name))
|
||||
if group_value in processed_results:
|
||||
processed_results[str(group_value)]["results"].append(result)
|
||||
|
||||
for group_value, data in processed_results.items():
|
||||
data["results"].sort(
|
||||
key=lambda x: x.get("created_at"), reverse=True
|
||||
)
|
||||
return processed_results
|
||||
|
||||
def process_results(self, results):
|
||||
if self.group_by_field_name in self.FIELD_MAPPER:
|
||||
processed_results = self.__query_multi_grouper(results=results)
|
||||
else:
|
||||
processed_results = self.__query_grouper(results=results)
|
||||
return processed_results
|
||||
|
||||
|
||||
class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
FIELD_MAPPER = {
|
||||
"labels__id": "label_ids",
|
||||
"assignees__id": "assignee_ids",
|
||||
"modules__id": "module_ids",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
queryset,
|
||||
group_by_field_name,
|
||||
sub_group_by_field_name,
|
||||
group_by_fields,
|
||||
sub_group_by_fields,
|
||||
count_filter,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(queryset, *args, **kwargs)
|
||||
self.group_by_field_name = group_by_field_name
|
||||
self.group_by_fields = group_by_fields
|
||||
self.sub_group_by_field_name = sub_group_by_field_name
|
||||
self.sub_group_by_fields = sub_group_by_fields
|
||||
self.count_filter = count_filter
|
||||
|
||||
def get_result(self, limit=30, cursor=None):
|
||||
# offset is page #
|
||||
# value is page limit
|
||||
if cursor is None:
|
||||
cursor = Cursor(0, 0, 0)
|
||||
|
||||
limit = min(limit, self.max_limit)
|
||||
|
||||
# Adjust the initial offset and stop based on the cursor and limit
|
||||
queryset = self.queryset
|
||||
if self.key:
|
||||
queryset = queryset.order_by(*self.key)
|
||||
|
||||
page = cursor.offset
|
||||
offset = cursor.offset * cursor.value
|
||||
stop = offset + (cursor.value or limit) + 1
|
||||
|
||||
if self.max_offset is not None and offset >= self.max_offset:
|
||||
raise BadPaginationError("Pagination offset too large")
|
||||
if offset < 0:
|
||||
raise BadPaginationError("Pagination offset cannot be negative")
|
||||
|
||||
# Compute the results
|
||||
results = {}
|
||||
queryset = queryset.annotate(
|
||||
row_number=Window(
|
||||
expression=RowNumber(),
|
||||
partition_by=[
|
||||
F(self.group_by_field_name),
|
||||
F(self.sub_group_by_field_name),
|
||||
],
|
||||
order_by=(
|
||||
*self.key,
|
||||
"-created_at",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Filter the results
|
||||
results = queryset.filter(row_number__gt=offset, row_number__lt=stop)
|
||||
|
||||
# Adjust cursors based on the grouped results for pagination
|
||||
next_cursor = Cursor(
|
||||
limit,
|
||||
page + 1,
|
||||
False,
|
||||
queryset.filter(row_number__gte=stop).exists(),
|
||||
)
|
||||
prev_cursor = Cursor(
|
||||
limit,
|
||||
page - 1,
|
||||
True,
|
||||
page > 0,
|
||||
)
|
||||
|
||||
count = queryset.count()
|
||||
|
||||
# Optionally, calculate the total count and max_hits if needed
|
||||
# This might require adjustments based on specific use cases
|
||||
max_hits = math.ceil(
|
||||
queryset.values(self.group_by_field_name)
|
||||
.annotate(
|
||||
count=Count(
|
||||
"id",
|
||||
filter=self.count_filter,
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
.order_by("-count")[0]["count"]
|
||||
/ limit
|
||||
)
|
||||
return CursorResult(
|
||||
results=results,
|
||||
next=next_cursor,
|
||||
prev=prev_cursor,
|
||||
hits=count,
|
||||
max_hits=max_hits,
|
||||
)
|
||||
|
||||
def __get_group_total_queryset(self):
|
||||
return (
|
||||
self.queryset.order_by(self.group_by_field_name)
|
||||
.values(self.group_by_field_name)
|
||||
.annotate(
|
||||
count=Count(
|
||||
"id",
|
||||
filter=self.count_filter,
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def __get_subgroup_total_queryset(self):
|
||||
return (
|
||||
self.queryset.values(
|
||||
self.group_by_field_name, self.sub_group_by_field_name
|
||||
)
|
||||
.annotate(
|
||||
count=Count("id", filter=self.count_filter, distinct=True)
|
||||
)
|
||||
.order_by()
|
||||
.values(
|
||||
self.group_by_field_name, self.sub_group_by_field_name, "count"
|
||||
)
|
||||
)
|
||||
|
||||
def __get_total_dict(self):
|
||||
total_group_dict = {}
|
||||
total_sub_group_dict = {}
|
||||
for group in self.__get_group_total_queryset():
|
||||
total_group_dict[str(group.get(self.group_by_field_name))] = (
|
||||
total_group_dict.get(
|
||||
str(group.get(self.group_by_field_name)), 0
|
||||
)
|
||||
+ (1 if group.get("count") == 0 else group.get("count"))
|
||||
)
|
||||
|
||||
# Sub group total values
|
||||
for item in self.__get_subgroup_total_queryset():
|
||||
group = str(item[self.group_by_field_name])
|
||||
subgroup = str(item[self.sub_group_by_field_name])
|
||||
count = item["count"]
|
||||
|
||||
if group not in total_sub_group_dict:
|
||||
total_sub_group_dict[str(group)] = {}
|
||||
|
||||
if subgroup not in total_sub_group_dict[group]:
|
||||
total_sub_group_dict[str(group)][str(subgroup)] = {}
|
||||
|
||||
total_sub_group_dict[group][subgroup] = count
|
||||
|
||||
return total_group_dict, total_sub_group_dict
|
||||
|
||||
def __get_field_dict(self):
|
||||
total_group_dict, total_sub_group_dict = self.__get_total_dict()
|
||||
|
||||
return {
|
||||
str(group): {
|
||||
"results": {
|
||||
str(sub_group): {
|
||||
"results": [],
|
||||
"total_results": total_sub_group_dict.get(
|
||||
str(group)
|
||||
).get(str(sub_group), 0),
|
||||
}
|
||||
for sub_group in total_sub_group_dict.get(str(group))
|
||||
},
|
||||
"total_results": total_group_dict.get(str(group), 0),
|
||||
}
|
||||
for group in self.group_by_fields
|
||||
}
|
||||
|
||||
def __query_multi_grouper(self, results):
|
||||
processed_results = self.__get_field_dict()
|
||||
# Preparing a dict to keep track of group IDs associated with each label ID
|
||||
result_group_mapping = defaultdict(set)
|
||||
result_sub_group_mapping = defaultdict(set)
|
||||
|
||||
# Iterate over results to fill the above dictionaries
|
||||
if self.group_by_field_name in self.FIELD_MAPPER:
|
||||
for result in results:
|
||||
result_id = result["id"]
|
||||
group_id = result[self.group_by_field_name]
|
||||
result_group_mapping[str(result_id)].add(str(group_id))
|
||||
|
||||
# Use the same calculation for the sub group
|
||||
if self.sub_group_by_field_name in self.FIELD_MAPPER:
|
||||
for result in results:
|
||||
result_id = result["id"]
|
||||
sub_group_id = result[self.sub_group_by_field_name]
|
||||
result_sub_group_mapping[str(result_id)].add(str(sub_group_id))
|
||||
|
||||
# Iterate over results
|
||||
for result in results:
|
||||
# Get the group value
|
||||
group_value = str(result.get(self.group_by_field_name))
|
||||
# Get the sub group value
|
||||
sub_group_value = str(result.get(self.sub_group_by_field_name))
|
||||
if (
|
||||
group_value in processed_results
|
||||
and sub_group_value
|
||||
in processed_results[str(group_value)]["results"]
|
||||
):
|
||||
if self.group_by_field_name in self.FIELD_MAPPER:
|
||||
# for multi grouper
|
||||
group_ids = list(result_group_mapping[str(result_id)])
|
||||
result[self.FIELD_MAPPER.get(self.group_by_field_name)] = (
|
||||
[] if "None" in group_ids else group_ids
|
||||
)
|
||||
if self.sub_group_by_field_name in self.FIELD_MAPPER:
|
||||
sub_group_ids = list(result_group_mapping[str(result_id)])
|
||||
# for multi groups
|
||||
result[self.FIELD_MAPPER.get(self.group_by_field_name)] = (
|
||||
[] if "None" in sub_group_ids else sub_group_ids
|
||||
)
|
||||
|
||||
processed_results[str(group_value)]["results"][
|
||||
str(sub_group_value)
|
||||
]["results"].append(result)
|
||||
return processed_results
|
||||
|
||||
def __query_grouper(self, results):
|
||||
processed_results = self.__get_field_dict()
|
||||
for result in results:
|
||||
group_value = str(result.get(self.group_by_field_name))
|
||||
sub_group_value = str(result.get(self.sub_group_by_field_name))
|
||||
processed_results[group_value]["results"][sub_group_value][
|
||||
"results"
|
||||
].append(result)
|
||||
return processed_results
|
||||
|
||||
def process_results(self, results):
|
||||
if (
|
||||
self.group_by_field_name in self.FIELD_MAPPER
|
||||
or self.sub_group_by_field_name in self.FIELD_MAPPER
|
||||
):
|
||||
processed_results = self.__query_multi_grouper(results=results)
|
||||
else:
|
||||
processed_results = self.__query_grouper(results=results)
|
||||
return processed_results
|
||||
|
||||
|
||||
class BasePaginator:
|
||||
"""BasePaginator class can be inherited by any View to return a paginated view"""
|
||||
@@ -171,6 +641,11 @@ class BasePaginator:
|
||||
cursor_cls=Cursor,
|
||||
extra_stats=None,
|
||||
controller=None,
|
||||
group_by_field_name=None,
|
||||
group_by_fields=None,
|
||||
sub_group_by_field_name=None,
|
||||
sub_group_by_fields=None,
|
||||
count_filter=None,
|
||||
**paginator_kwargs,
|
||||
):
|
||||
"""Paginate the request"""
|
||||
@@ -178,15 +653,27 @@ class BasePaginator:
|
||||
|
||||
# Convert the cursor value to integer and float from string
|
||||
input_cursor = None
|
||||
if request.GET.get(self.cursor_name):
|
||||
try:
|
||||
input_cursor = cursor_cls.from_string(
|
||||
request.GET.get(self.cursor_name)
|
||||
)
|
||||
except ValueError:
|
||||
raise ParseError(detail="Invalid cursor parameter.")
|
||||
try:
|
||||
input_cursor = cursor_cls.from_string(
|
||||
request.GET.get(self.cursor_name, f"{per_page}:0:0"),
|
||||
)
|
||||
except ValueError:
|
||||
raise ParseError(detail="Invalid cursor parameter.")
|
||||
|
||||
if not paginator:
|
||||
if group_by_field_name:
|
||||
paginator_kwargs["group_by_field_name"] = group_by_field_name
|
||||
paginator_kwargs["group_by_fields"] = group_by_fields
|
||||
paginator_kwargs["count_filter"] = count_filter
|
||||
|
||||
if sub_group_by_field_name:
|
||||
paginator_kwargs["sub_group_by_field_name"] = (
|
||||
sub_group_by_field_name
|
||||
)
|
||||
paginator_kwargs["sub_group_by_fields"] = (
|
||||
sub_group_by_fields
|
||||
)
|
||||
|
||||
paginator = paginator_cls(**paginator_kwargs)
|
||||
|
||||
try:
|
||||
@@ -196,11 +683,11 @@ class BasePaginator:
|
||||
except BadPaginationError:
|
||||
raise ParseError(detail="Error in parsing")
|
||||
|
||||
# Serialize result according to the on_result function
|
||||
if on_results:
|
||||
results = on_results(cursor_result.results)
|
||||
else:
|
||||
results = cursor_result.results
|
||||
|
||||
if group_by_field_name:
|
||||
results = paginator.process_results(results=results)
|
||||
|
||||
# Add Manipulation functions to the response
|
||||
if controller is not None:
|
||||
@@ -211,6 +698,9 @@ class BasePaginator:
|
||||
# Return the response
|
||||
response = Response(
|
||||
{
|
||||
"grouped_by": group_by_field_name,
|
||||
"sub_grouped_by": sub_group_by_field_name,
|
||||
"total_count": (cursor_result.hits),
|
||||
"next_cursor": str(cursor_result.next),
|
||||
"prev_cursor": str(cursor_result.prev),
|
||||
"next_page_results": cursor_result.next.has_results,
|
||||
|
||||
@@ -34,4 +34,4 @@ posthog==3.0.2
|
||||
cryptography==42.0.4
|
||||
lxml==4.9.3
|
||||
boto3==1.28.40
|
||||
|
||||
ruff==0.3.1
|
||||
|
||||
25
packages/types/src/issues/base.d.ts
vendored
25
packages/types/src/issues/base.d.ts
vendored
@@ -1,3 +1,6 @@
|
||||
import { StateGroup } from "components/states";
|
||||
import { TIssuePriorities } from "../issues";
|
||||
|
||||
// issues
|
||||
export * from "./issue";
|
||||
export * from "./issue_reaction";
|
||||
@@ -7,16 +10,28 @@ export * from "./issue_relation";
|
||||
export * from "./issue_sub_issues";
|
||||
export * from "./activity/base";
|
||||
|
||||
export type TLoader = "init-loader" | "mutation" | undefined;
|
||||
export type TLoader = "init-loader" | "mutation" | "pagination" | undefined;
|
||||
|
||||
export type TGroupedIssues = {
|
||||
[group_id: string]: string[];
|
||||
};
|
||||
|
||||
export type TSubGroupedIssues = {
|
||||
[sub_grouped_id: string]: {
|
||||
[group_id: string]: string[];
|
||||
};
|
||||
[sub_grouped_id: string]: TGroupedIssues;
|
||||
};
|
||||
|
||||
export type TUnGroupedIssues = string[];
|
||||
export type TIssues = TGroupedIssues | TSubGroupedIssues;
|
||||
|
||||
export type TPaginationData = {
|
||||
nextCursor: string;
|
||||
prevCursor: string;
|
||||
nextPageResults: boolean;
|
||||
};
|
||||
|
||||
export type TIssuePaginationData = {
|
||||
[group_id: string]: TPaginationData;
|
||||
};
|
||||
|
||||
export type TGroupedIssueCount = {
|
||||
[group_id: string]: number;
|
||||
};
|
||||
|
||||
44
packages/types/src/issues/issue.d.ts
vendored
44
packages/types/src/issues/issue.d.ts
vendored
@@ -4,15 +4,15 @@ import { TIssueLink } from "./issue_link";
|
||||
import { TIssueReaction } from "./issue_reaction";
|
||||
|
||||
// new issue structure types
|
||||
export type TIssue = {
|
||||
|
||||
export type TBaseIssue = {
|
||||
id: string;
|
||||
sequence_id: number;
|
||||
name: string;
|
||||
description_html: string;
|
||||
sort_order: number;
|
||||
|
||||
state_id: string;
|
||||
priority: TIssuePriorities;
|
||||
state_id: string | null;
|
||||
priority: TIssuePriorities | null;
|
||||
label_ids: string[];
|
||||
assignee_ids: string[];
|
||||
estimate_point: number | null;
|
||||
@@ -21,7 +21,7 @@ export type TIssue = {
|
||||
attachment_count: number;
|
||||
link_count: number;
|
||||
|
||||
project_id: string;
|
||||
project_id: string | null;
|
||||
parent_id: string | null;
|
||||
cycle_id: string | null;
|
||||
module_ids: string[] | null;
|
||||
@@ -37,9 +37,14 @@ export type TIssue = {
|
||||
updated_by: string;
|
||||
|
||||
is_draft: boolean;
|
||||
};
|
||||
|
||||
export type TIssue = TBaseIssue & {
|
||||
description_html?: string;
|
||||
is_subscribed?: boolean;
|
||||
|
||||
parent?: partial<TIssue>;
|
||||
|
||||
issue_reactions?: TIssueReaction[];
|
||||
issue_attachment?: TIssueAttachment[];
|
||||
issue_link?: TIssueLink[];
|
||||
@@ -51,3 +56,32 @@ export type TIssue = {
|
||||
export type TIssueMap = {
|
||||
[issue_id: string]: TIssue;
|
||||
};
|
||||
|
||||
type TIssueResponseResults =
|
||||
| TBaseIssue[]
|
||||
| {
|
||||
[key: string]: {
|
||||
results:
|
||||
| TBaseIssue[]
|
||||
| {
|
||||
[key: string]: {
|
||||
results: TBaseIssue[];
|
||||
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;
|
||||
};
|
||||
|
||||
31
packages/types/src/view-props.d.ts
vendored
31
packages/types/src/view-props.d.ts
vendored
@@ -1,9 +1,4 @@
|
||||
export type TIssueLayouts =
|
||||
| "list"
|
||||
| "kanban"
|
||||
| "calendar"
|
||||
| "spreadsheet"
|
||||
| "gantt_chart";
|
||||
import { EIssueLayoutTypes } from "constants/issue";
|
||||
|
||||
export type TIssueGroupByOptions =
|
||||
| "state"
|
||||
@@ -13,9 +8,9 @@ export type TIssueGroupByOptions =
|
||||
| "state_detail.group"
|
||||
| "project"
|
||||
| "assignees"
|
||||
| "mentions"
|
||||
| "cycle"
|
||||
| "module"
|
||||
| "target_date"
|
||||
| null;
|
||||
|
||||
export type TIssueOrderByOptions =
|
||||
@@ -32,10 +27,10 @@ export type TIssueOrderByOptions =
|
||||
| "-assignees__first_name"
|
||||
| "labels__name"
|
||||
| "-labels__name"
|
||||
| "modules__name"
|
||||
| "-modules__name"
|
||||
| "cycle__name"
|
||||
| "-cycle__name"
|
||||
| "issue_module__module__name"
|
||||
| "-issue_module__module__name"
|
||||
| "issue_cycle__cycle__name"
|
||||
| "-issue_cycle__cycle__name"
|
||||
| "target_date"
|
||||
| "-target_date"
|
||||
| "estimate_point"
|
||||
@@ -72,7 +67,9 @@ export type TIssueParams =
|
||||
| "order_by"
|
||||
| "type"
|
||||
| "sub_issue"
|
||||
| "show_empty_groups";
|
||||
| "show_empty_groups"
|
||||
| "cursor"
|
||||
| "per_page";
|
||||
|
||||
export type TCalendarLayouts = "month" | "week";
|
||||
|
||||
@@ -82,9 +79,9 @@ export interface IIssueFilterOptions {
|
||||
created_by?: string[] | null;
|
||||
labels?: string[] | null;
|
||||
priority?: string[] | null;
|
||||
project?: string[] | null;
|
||||
cycle?: string[] | null;
|
||||
module?: string[] | null;
|
||||
project?: string[] | null;
|
||||
start_date?: string[] | null;
|
||||
state?: string[] | null;
|
||||
state_group?: string[] | null;
|
||||
@@ -191,3 +188,11 @@ export interface IWorkspaceGlobalViewProps {
|
||||
display_filters: IWorkspaceIssueDisplayFilterOptions | undefined;
|
||||
display_properties: IIssueDisplayProperties;
|
||||
}
|
||||
|
||||
export interface IssuePaginationOptions {
|
||||
canGroup: boolean;
|
||||
perPageCount: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
groupedBy?: TIssueGroupByOptions;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none";
|
||||
interface IPriorityIcon {
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
priority: TIssuePriorities;
|
||||
priority: TIssuePriorities | undefined | null;
|
||||
size?: number;
|
||||
withContainer?: boolean;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export const PriorityIcon: React.FC<IPriorityIcon> = (props) => {
|
||||
low: SignalLow,
|
||||
none: Ban,
|
||||
};
|
||||
const Icon = icons[priority];
|
||||
const Icon = icons[priority ?? "none"];
|
||||
|
||||
if (!Icon) return null;
|
||||
|
||||
@@ -41,7 +41,7 @@ export const PriorityIcon: React.FC<IPriorityIcon> = (props) => {
|
||||
<div
|
||||
className={cn(
|
||||
"grid place-items-center border rounded p-0.5 flex-shrink-0",
|
||||
priorityClasses[priority],
|
||||
priorityClasses[priority ?? "none"],
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// hooks
|
||||
import { useProjectState } from "@/hooks/store";
|
||||
import { ISearchIssueResponse } from "@plane/types";
|
||||
|
||||
export const BulkDeleteIssuesModalItem: React.FC<any> = observer((props) => {
|
||||
const { issue, delete_issue_ids, identifier } = props;
|
||||
const { getStateById } = useProjectState();
|
||||
interface Props {
|
||||
issue: ISearchIssueResponse;
|
||||
canDeleteIssueIds: boolean;
|
||||
identifier: string | undefined;
|
||||
}
|
||||
|
||||
const color = getStateById(issue.state_id)?.color;
|
||||
export const BulkDeleteIssuesModalItem: React.FC<Props> = observer((props: Props) => {
|
||||
const { issue, canDeleteIssueIds, identifier } = props;
|
||||
|
||||
const color = issue.state__color;
|
||||
|
||||
return (
|
||||
<Combobox.Option
|
||||
@@ -21,7 +26,7 @@ export const BulkDeleteIssuesModalItem: React.FC<any> = observer((props) => {
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={delete_issue_ids} readOnly />
|
||||
<input type="checkbox" checked={canDeleteIssueIds} readOnly />
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import useSWR from "swr";
|
||||
import { Search } from "lucide-react";
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import { IUser, TIssue } from "@plane/types";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
//plane
|
||||
import { ISearchIssueResponse, IUser } from "@plane/types";
|
||||
import { Button, Loader, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
//components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
//constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { PROJECT_ISSUES_LIST } from "@/constants/fetch-keys";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
//hooks
|
||||
import { useIssues, useProject } from "@/hooks/store";
|
||||
import { IssueService } from "@/services/issue";
|
||||
import useDebounce from "@/hooks/use-debounce";
|
||||
// services
|
||||
import { ProjectService } from "@/services/project";
|
||||
// ui
|
||||
// icons
|
||||
// types
|
||||
// store hooks
|
||||
// components
|
||||
import { BulkDeleteIssuesModalItem } from "./bulk-delete-issues-modal-item";
|
||||
// constants
|
||||
|
||||
type FormInput = {
|
||||
delete_issue_ids: string[];
|
||||
@@ -32,7 +32,7 @@ type Props = {
|
||||
user: IUser | undefined;
|
||||
};
|
||||
|
||||
const issueService = new IssueService();
|
||||
const projectService = new ProjectService();
|
||||
|
||||
export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose } = props;
|
||||
@@ -46,13 +46,23 @@ export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
// fetching project issues.
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId && isOpen ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
|
||||
workspaceSlug && projectId && isOpen
|
||||
? () => issueService.getIssues(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const debouncedSearchTerm: string = useDebounce(query, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !workspaceSlug || !projectId) return;
|
||||
|
||||
setIsSearching(true);
|
||||
projectService
|
||||
.projectIssuesSearch(workspaceSlug.toString(), projectId.toString(), {
|
||||
search: debouncedSearchTerm,
|
||||
workspace_search: false,
|
||||
})
|
||||
.then((res: ISearchIssueResponse[]) => setIssues(res))
|
||||
.finally(() => setIsSearching(false));
|
||||
}, [debouncedSearchTerm, isOpen, projectId, workspaceSlug]);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -106,14 +116,33 @@ export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
|
||||
|
||||
const projectDetails = getProjectById(projectId as string);
|
||||
|
||||
const filteredIssues: TIssue[] =
|
||||
query === ""
|
||||
? Object.values(issues ?? {})
|
||||
: Object.values(issues ?? {})?.filter(
|
||||
(issue) =>
|
||||
issue.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
`${projectDetails?.identifier}-${issue.sequence_id}`.toLowerCase().includes(query.toLowerCase())
|
||||
) ?? [];
|
||||
const issueList =
|
||||
issues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mb-2 mt-4 px-3 text-xs font-semibold text-custom-text-100">Select issues to delete</h2>
|
||||
)}
|
||||
<ul className="text-sm text-custom-text-200">
|
||||
{issues.map((issue) => (
|
||||
<BulkDeleteIssuesModalItem
|
||||
issue={issue}
|
||||
identifier={projectDetails?.identifier}
|
||||
canDeleteIssueIds={watch("delete_issue_ids").includes(issue.id)}
|
||||
key={issue.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
|
||||
<EmptyState
|
||||
type={
|
||||
query === "" ? EmptyStateType.ISSUE_RELATION_EMPTY_STATE : EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE
|
||||
}
|
||||
layout="screen-simple"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||
@@ -159,40 +188,20 @@ export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
|
||||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-custom-border-200 overflow-y-auto"
|
||||
>
|
||||
{filteredIssues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mb-2 mt-4 px-3 text-xs font-semibold text-custom-text-100">
|
||||
Select issues to delete
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-custom-text-200">
|
||||
{filteredIssues.map((issue) => (
|
||||
<BulkDeleteIssuesModalItem
|
||||
issue={issue}
|
||||
identifier={projectDetails?.identifier}
|
||||
delete_issue_ids={watch("delete_issue_ids").includes(issue.id)}
|
||||
key={issue.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
{isSearching ? (
|
||||
<Loader className="space-y-3 p-3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
|
||||
<EmptyState
|
||||
type={
|
||||
query === ""
|
||||
? EmptyStateType.ISSUE_RELATION_EMPTY_STATE
|
||||
: EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE
|
||||
}
|
||||
layout="screen-simple"
|
||||
/>
|
||||
</div>
|
||||
<>{issueList}</>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
|
||||
{filteredIssues.length > 0 && (
|
||||
{issues.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
|
||||
@@ -73,6 +73,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
|
||||
useEffect(() => {
|
||||
if (!isOpen || !workspaceSlug || !projectId) return;
|
||||
|
||||
setIsSearching(true);
|
||||
projectService
|
||||
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
|
||||
search: debouncedSearchTerm,
|
||||
|
||||
@@ -24,10 +24,9 @@ const RenderIfVisible: React.FC<Props> = (props) => {
|
||||
as = "div",
|
||||
children,
|
||||
classNames = "",
|
||||
alwaysRender = false, //render the children even if it is not visble in root
|
||||
alwaysRender = false, //render the children even if it is not visible in root
|
||||
placeholderChildren = null, //placeholder children
|
||||
pauseHeightUpdateWhileRendering = false, //while this is true the height of the blocks are maintained
|
||||
changingReference, //This is to force render when this reference is changed
|
||||
} = props;
|
||||
const [shouldVisible, setShouldVisible] = useState<boolean>(alwaysRender);
|
||||
const placeholderHeight = useRef<string>(defaultHeight);
|
||||
@@ -48,7 +47,7 @@ const RenderIfVisible: React.FC<Props> = (props) => {
|
||||
// } else {
|
||||
// setShouldVisible(entries[0].isIntersecting);
|
||||
// }
|
||||
setShouldVisible(entries[0].isIntersecting);
|
||||
setShouldVisible(entries[entries.length - 1].isIntersecting);
|
||||
},
|
||||
{
|
||||
root: root?.current,
|
||||
@@ -63,7 +62,7 @@ const RenderIfVisible: React.FC<Props> = (props) => {
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [intersectionRef, children, changingReference, root, verticalOffset, horizontalOffset]);
|
||||
}, [intersectionRef, children, root, verticalOffset, horizontalOffset]);
|
||||
|
||||
//Set height after render
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import useSWR from "swr";
|
||||
import { CalendarCheck } from "lucide-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// types
|
||||
import { ICycle, TIssue } from "@plane/types";
|
||||
import { ICycle } from "@plane/types";
|
||||
// ui
|
||||
import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui";
|
||||
// components
|
||||
@@ -20,7 +20,7 @@ import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useIssues, useProject } from "@/hooks/store";
|
||||
import { useIssueDetail, useIssues, useProject } from "@/hooks/store";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
|
||||
export type ActiveCycleStatsProps = {
|
||||
@@ -47,17 +47,20 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
}
|
||||
};
|
||||
const {
|
||||
issues: { fetchActiveCycleIssues },
|
||||
issues: { getActiveCycleId, fetchActiveCycleIssues, fetchNextActiveCycleIssues },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
const { data: activeCycleIssues } = useSWR(
|
||||
useSWR(
|
||||
workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES_WITH_PARAMS(cycle.id, { priority: "urgent,high" }) : null,
|
||||
workspaceSlug && projectId && cycle.id ? () => fetchActiveCycleIssues(workspaceSlug, projectId, cycle.id) : null
|
||||
workspaceSlug && projectId && cycle.id ? () => fetchActiveCycleIssues(workspaceSlug, projectId, 6, cycle.id) : null
|
||||
);
|
||||
|
||||
const cycleIssues = activeCycleIssues ?? [];
|
||||
const cycleIssueDetails = getActiveCycleId(cycle.id);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 min-h-[17rem] overflow-hidden col-span-1 lg:col-span-2 xl:col-span-1 border border-custom-border-200 rounded-lg">
|
||||
@@ -132,52 +135,73 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-1 h-full w-full overflow-y-auto vertical-scrollbar scrollbar-sm">
|
||||
{cycleIssues ? (
|
||||
cycleIssues.length > 0 ? (
|
||||
cycleIssues.map((issue: TIssue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
className="group flex cursor-pointer items-center justify-between gap-2 rounded-md hover:bg-custom-background-90 p-1"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-grow w-full min-w-24 truncate">
|
||||
<PriorityIcon priority={issue.priority} withContainer size={12} />
|
||||
{cycleIssueDetails && cycleIssueDetails.issueIds ? (
|
||||
cycleIssueDetails.issueCount > 0 ? (
|
||||
<>
|
||||
{cycleIssueDetails.issueIds.map((issueId: string) => {
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
<Tooltip
|
||||
tooltipHeading="Issue ID"
|
||||
tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`}
|
||||
if (!issue) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={issue.id}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
className="group flex cursor-pointer items-center justify-between gap-2 rounded-md hover:bg-custom-background-90 p-1"
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{currentProjectDetails?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="text-[0.825rem] text-custom-text-100 truncate">{issue.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<StateDropdown
|
||||
value={issue.state_id ?? undefined}
|
||||
onChange={() => {}}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
disabled
|
||||
buttonVariant="background-with-text"
|
||||
buttonContainerClassName="cursor-pointer max-w-24"
|
||||
showTooltip
|
||||
/>
|
||||
{issue.target_date && (
|
||||
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}>
|
||||
<div className="h-full flex truncate items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80 group-hover:bg-custom-background-100 cursor-pointer">
|
||||
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="text-xs truncate">
|
||||
{renderFormattedDateWithoutYear(issue.target_date)}
|
||||
<div className="flex items-center gap-1.5 flex-grow w-full min-w-24 truncate">
|
||||
<PriorityIcon priority={issue.priority} withContainer size={12} />
|
||||
|
||||
<Tooltip
|
||||
tooltipHeading="Issue ID"
|
||||
tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{currentProjectDetails?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="text-[0.825rem] text-custom-text-100 truncate">{issue.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<StateDropdown
|
||||
value={issue.state_id ?? undefined}
|
||||
onChange={() => {}}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
disabled
|
||||
buttonVariant="background-with-text"
|
||||
buttonContainerClassName="cursor-pointer max-w-24"
|
||||
showTooltip
|
||||
/>
|
||||
{issue.target_date && (
|
||||
<Tooltip
|
||||
tooltipHeading="Target Date"
|
||||
tooltipContent={renderFormattedDate(issue.target_date)}
|
||||
>
|
||||
<div className="h-full flex truncate items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80 group-hover:bg-custom-background-100 cursor-pointer">
|
||||
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="text-xs truncate">
|
||||
{renderFormattedDateWithoutYear(issue.target_date)}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{cycleIssueDetails.nextPageResults && (
|
||||
<div
|
||||
className={
|
||||
"h-11 relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm text-custom-primary-100 hover:underline cursor-pointer"
|
||||
}
|
||||
onClick={() => fetchNextActiveCycleIssues(workspaceSlug, projectId, cycle.id)}
|
||||
>
|
||||
Load more ↓
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState
|
||||
|
||||
@@ -3,13 +3,13 @@ import router from "next/router";
|
||||
//components
|
||||
// icons
|
||||
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// hooks
|
||||
// constants
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues";
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue";
|
||||
import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue";
|
||||
import { useIssues, useCycle, useProjectState, useLabel, useMember } from "@/hooks/store";
|
||||
|
||||
export const CycleMobileHeader = () => {
|
||||
@@ -30,7 +30,7 @@ export const CycleMobileHeader = () => {
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { FC } from "react";
|
||||
import { FC, useCallback } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import { ICycle } from "@plane/types";
|
||||
// hooks
|
||||
import { CycleGanttBlock } from "@/components/cycles";
|
||||
import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "@/components/gantt-chart";
|
||||
import { getDate } from "@/helpers/date-time.helper";
|
||||
import { useCycle } from "@/hooks/store";
|
||||
// components
|
||||
// types
|
||||
// constants
|
||||
import { CycleGanttBlock } from "@/components/cycles";
|
||||
import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar, ChartDataType } from "@/components/gantt-chart";
|
||||
import { getMonthChartItemPositionWidthInMonth } from "@/components/gantt-chart/views";
|
||||
// helpers
|
||||
import { getDate } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
@@ -24,6 +24,28 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
||||
// store hooks
|
||||
const { getCycleById, updateCycleDetails } = useCycle();
|
||||
|
||||
const getBlockById = useCallback(
|
||||
(id: string, currentViewData?: ChartDataType | undefined) => {
|
||||
const cycle = getCycleById(id);
|
||||
const block = {
|
||||
data: cycle,
|
||||
id: cycle?.id ?? "",
|
||||
sort_order: cycle?.sort_order ?? 0,
|
||||
start_date: getDate(cycle?.start_date),
|
||||
target_date: getDate(cycle?.end_date),
|
||||
};
|
||||
|
||||
if (currentViewData) {
|
||||
return {
|
||||
...block,
|
||||
position: getMonthChartItemPositionWidthInMonth(currentViewData, block),
|
||||
};
|
||||
}
|
||||
return block;
|
||||
},
|
||||
[getCycleById]
|
||||
);
|
||||
|
||||
const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => {
|
||||
if (!workspaceSlug || !cycle) return;
|
||||
|
||||
@@ -33,28 +55,13 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
|
||||
await updateCycleDetails(workspaceSlug.toString(), cycle.project_id, cycle.id, payload);
|
||||
};
|
||||
|
||||
const blockFormat = (blocks: (ICycle | null)[]) => {
|
||||
if (!blocks) return [];
|
||||
|
||||
const filteredBlocks = blocks.filter((b) => b !== null && b.start_date && b.end_date);
|
||||
|
||||
const structuredBlocks = filteredBlocks.map((block) => ({
|
||||
data: block,
|
||||
id: block?.id ?? "",
|
||||
sort_order: block?.sort_order ?? 0,
|
||||
start_date: getDate(block?.start_date),
|
||||
target_date: getDate(block?.end_date),
|
||||
}));
|
||||
|
||||
return structuredBlocks;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto">
|
||||
<GanttChartRoot
|
||||
title="Cycles"
|
||||
loaderTitle="Cycles"
|
||||
blocks={cycleIds ? blockFormat(cycleIds.map((c) => getCycleById(c))) : null}
|
||||
blockIds={cycleIds}
|
||||
getBlockById={getBlockById}
|
||||
blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)}
|
||||
sidebarToRender={(props) => <CycleGanttSidebar {...props} />}
|
||||
blockToRender={(data: ICycle) => <CycleGanttBlock cycleId={data.id} />}
|
||||
|
||||
@@ -25,7 +25,7 @@ export const AssignedUpcomingIssueListItem: React.FC<IssueListItemProps> = obser
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined;
|
||||
|
||||
if (!issueDetails) return null;
|
||||
if (!issueDetails || !issueDetails.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
|
||||
@@ -73,7 +73,7 @@ export const AssignedOverdueIssueListItem: React.FC<IssueListItemProps> = observ
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined;
|
||||
|
||||
if (!issueDetails) return null;
|
||||
if (!issueDetails || !issueDetails.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? [];
|
||||
@@ -120,7 +120,7 @@ export const AssignedCompletedIssueListItem: React.FC<IssueListItemProps> = obse
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId);
|
||||
|
||||
if (!issueDetails) return null;
|
||||
if (!issueDetails || !issueDetails.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
|
||||
@@ -152,7 +152,7 @@ export const CreatedUpcomingIssueListItem: React.FC<IssueListItemProps> = observ
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return null;
|
||||
if (!issue || !issue.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
const targetDate = getDate(issue.target_date);
|
||||
@@ -203,7 +203,7 @@ export const CreatedOverdueIssueListItem: React.FC<IssueListItemProps> = observe
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return null;
|
||||
if (!issue || !issue.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
@@ -255,7 +255,7 @@ export const CreatedCompletedIssueListItem: React.FC<IssueListItemProps> = obser
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return null;
|
||||
if (!issue || !issue.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
||||
const { setPeekIssue } = useIssueDetail();
|
||||
|
||||
const handleIssuePeekOverview = (issue: TIssue) =>
|
||||
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
|
||||
issue.project_id && setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
|
||||
|
||||
const filterParams = getRedirectionFilters(tab);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ type Props = TDropdownProps & {
|
||||
dropdownArrowClassName?: string;
|
||||
onChange: (val: string | null) => void;
|
||||
onClose?: () => void;
|
||||
projectId: string;
|
||||
projectId: string | undefined;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
@@ -142,7 +142,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
</button>
|
||||
)}
|
||||
</Combobox.Button>
|
||||
{isOpen && (
|
||||
{isOpen && projectId && (
|
||||
<CycleOptions isOpen={isOpen} projectId={projectId} placement={placement} referenceElement={referenceElement} />
|
||||
)}
|
||||
</Combobox>
|
||||
|
||||
@@ -23,7 +23,7 @@ type Props = TDropdownProps & {
|
||||
dropdownArrowClassName?: string;
|
||||
onChange: (val: number | null) => void;
|
||||
onClose?: () => void;
|
||||
projectId: string;
|
||||
projectId: string | undefined;
|
||||
value: number | null;
|
||||
};
|
||||
|
||||
@@ -107,10 +107,10 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const selectedEstimate = value !== null ? getEstimatePointValue(value, projectId) : null;
|
||||
const selectedEstimate = value !== null && projectId ? getEstimatePointValue(value, projectId) : null;
|
||||
|
||||
const onOpen = () => {
|
||||
if (!activeEstimate && workspaceSlug) fetchProjectEstimates(workspaceSlug, projectId);
|
||||
if (!activeEstimate && workspaceSlug && projectId) fetchProjectEstimates(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ type Props = TDropdownProps & {
|
||||
button?: ReactNode;
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
projectId: string;
|
||||
projectId: string | undefined;
|
||||
showCount?: boolean;
|
||||
onClose?: () => void;
|
||||
} & (
|
||||
@@ -284,7 +284,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
</button>
|
||||
)}
|
||||
</Combobox.Button>
|
||||
{isOpen && (
|
||||
{isOpen && projectId && (
|
||||
<ModuleOptions
|
||||
isOpen={isOpen}
|
||||
projectId={projectId}
|
||||
|
||||
@@ -25,7 +25,7 @@ type Props = TDropdownProps & {
|
||||
highlightUrgent?: boolean;
|
||||
onChange: (val: TIssuePriorities) => void;
|
||||
onClose?: () => void;
|
||||
value: TIssuePriorities;
|
||||
value: TIssuePriorities | undefined | null;
|
||||
};
|
||||
|
||||
type ButtonProps = {
|
||||
@@ -36,7 +36,7 @@ type ButtonProps = {
|
||||
hideText?: boolean;
|
||||
isActive?: boolean;
|
||||
highlightUrgent: boolean;
|
||||
priority: TIssuePriorities;
|
||||
priority: TIssuePriorities | undefined;
|
||||
showTooltip: boolean;
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ const BorderButton = (props: ButtonProps) => {
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex items-center gap-1.5 border-[0.5px] rounded text-xs px-2 py-0.5",
|
||||
priorityClasses[priority],
|
||||
priorityClasses[priority || "none"],
|
||||
{
|
||||
// compact the icons if text is hidden
|
||||
"px-0.5": hideText,
|
||||
@@ -150,7 +150,7 @@ const BackgroundButton = (props: ButtonProps) => {
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5",
|
||||
priorityClasses[priority],
|
||||
priorityClasses[priority || "none"],
|
||||
{
|
||||
// compact the icons if text is hidden
|
||||
"px-0.5": hideText,
|
||||
@@ -227,7 +227,7 @@ const TransparentButton = (props: ButtonProps) => {
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 hover:bg-custom-background-80",
|
||||
priorityClasses[priority],
|
||||
priorityClasses[priority || "none"],
|
||||
{
|
||||
// compact the icons if text is hidden
|
||||
"px-0.5": hideText,
|
||||
@@ -364,8 +364,8 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
const ButtonToRender = BORDER_BUTTON_VARIANTS.includes(buttonVariant)
|
||||
? BorderButton
|
||||
: BACKGROUND_BUTTON_VARIANTS.includes(buttonVariant)
|
||||
? BackgroundButton
|
||||
: TransparentButton;
|
||||
? BackgroundButton
|
||||
: TransparentButton;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
@@ -415,7 +415,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
<ButtonToRender
|
||||
priority={value}
|
||||
priority={value ?? undefined}
|
||||
className={cn(buttonClassName, {
|
||||
"text-white": resolvedTheme === "dark",
|
||||
})}
|
||||
|
||||
@@ -24,8 +24,8 @@ type Props = TDropdownProps & {
|
||||
dropdownArrowClassName?: string;
|
||||
onChange: (val: string) => void;
|
||||
onClose?: () => void;
|
||||
projectId: string;
|
||||
value: string;
|
||||
projectId: string | undefined;
|
||||
value: string | undefined | null;
|
||||
};
|
||||
|
||||
export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
@@ -95,12 +95,13 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
const selectedState = stateValue ? getStateById(stateValue) : undefined;
|
||||
|
||||
const onOpen = async () => {
|
||||
if (!statesList && workspaceSlug) {
|
||||
if (!statesList && workspaceSlug && projectId) {
|
||||
setStateLoader(true);
|
||||
await fetchProjectStates(workspaceSlug, projectId);
|
||||
setStateLoader(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) onOpen();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -10,10 +10,12 @@ import { useIssueDetail } from "@/hooks/store";
|
||||
import { BLOCK_HEIGHT } from "../constants";
|
||||
import { ChartAddBlock, ChartDraggable } from "../helpers";
|
||||
import { useGanttChart } from "../hooks";
|
||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||
import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types";
|
||||
|
||||
type Props = {
|
||||
block: IGanttBlock;
|
||||
blockId: string;
|
||||
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
|
||||
showAllBlocks: boolean;
|
||||
blockToRender: (data: any) => React.ReactNode;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
enableBlockLeftResize: boolean;
|
||||
@@ -25,7 +27,9 @@ type Props = {
|
||||
|
||||
export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
block,
|
||||
blockId,
|
||||
getBlockById,
|
||||
showAllBlocks,
|
||||
blockToRender,
|
||||
blockUpdateHandler,
|
||||
enableBlockLeftResize,
|
||||
@@ -35,9 +39,14 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
ganttContainerRef,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { updateActiveBlockId, isBlockActive } = useGanttChart();
|
||||
const { currentViewData, updateActiveBlockId, isBlockActive } = useGanttChart();
|
||||
const { peekIssue } = useIssueDetail();
|
||||
|
||||
const block = getBlockById(blockId, currentViewData);
|
||||
|
||||
// hide the block if it doesn't have start and target dates and showAllBlocks is false
|
||||
if (!block || (!showAllBlocks && !(block.start_date && block.target_date))) return null;
|
||||
|
||||
const isBlockVisibleOnChart = block.start_date && block.target_date;
|
||||
|
||||
const handleChartBlockPosition = (
|
||||
@@ -72,7 +81,6 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`block-${block.id}`}
|
||||
className="relative min-w-full w-max"
|
||||
style={{
|
||||
height: `${BLOCK_HEIGHT}px`,
|
||||
@@ -80,11 +88,11 @@ export const GanttChartBlock: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
<div
|
||||
className={cn("relative h-full", {
|
||||
"bg-custom-background-80": isBlockActive(block.id),
|
||||
"bg-custom-background-80": isBlockActive(blockId),
|
||||
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
|
||||
peekIssue?.issueId === block.data.id,
|
||||
})}
|
||||
onMouseEnter={() => updateActiveBlockId(block.id)}
|
||||
onMouseEnter={() => updateActiveBlockId(blockId)}
|
||||
onMouseLeave={() => updateActiveBlockId(null)}
|
||||
>
|
||||
{isBlockVisibleOnChart ? (
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { FC } from "react";
|
||||
// components
|
||||
import { HEADER_HEIGHT } from "../constants";
|
||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||
import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types";
|
||||
import { GanttChartBlock } from "./block";
|
||||
// types
|
||||
// constants
|
||||
|
||||
export type GanttChartBlocksProps = {
|
||||
itemsContainerWidth: number;
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockIds: string[];
|
||||
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
|
||||
blockToRender: (data: any) => React.ReactNode;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
enableBlockLeftResize: boolean;
|
||||
@@ -22,9 +23,10 @@ export type GanttChartBlocksProps = {
|
||||
export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
|
||||
const {
|
||||
itemsContainerWidth,
|
||||
blocks,
|
||||
blockIds,
|
||||
blockToRender,
|
||||
blockUpdateHandler,
|
||||
getBlockById,
|
||||
enableBlockLeftResize,
|
||||
enableBlockRightResize,
|
||||
enableBlockMove,
|
||||
@@ -41,24 +43,21 @@ export const GanttChartBlocksList: FC<GanttChartBlocksProps> = (props) => {
|
||||
transform: `translateY(${HEADER_HEIGHT}px)`,
|
||||
}}
|
||||
>
|
||||
{blocks?.map((block) => {
|
||||
// hide the block if it doesn't have start and target dates and showAllBlocks is false
|
||||
if (!showAllBlocks && !(block.start_date && block.target_date)) return;
|
||||
|
||||
return (
|
||||
<GanttChartBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
blockToRender={blockToRender}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableAddBlock={enableAddBlock}
|
||||
ganttContainerRef={ganttContainerRef}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{blockIds?.map((blockId) => (
|
||||
<GanttChartBlock
|
||||
key={blockId}
|
||||
blockId={blockId}
|
||||
getBlockById={getBlockById}
|
||||
showAllBlocks={showAllBlocks}
|
||||
blockToRender={blockToRender}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableAddBlock={enableAddBlock}
|
||||
ganttContainerRef={ganttContainerRef}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,11 +6,11 @@ import { VIEWS_LIST } from "@/components/gantt-chart/data";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// types
|
||||
import { useGanttChart } from "../hooks/use-gantt-chart";
|
||||
import { IGanttBlock, TGanttViews } from "../types";
|
||||
import { TGanttViews } from "../types";
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockIds: string[];
|
||||
fullScreenMode: boolean;
|
||||
handleChartView: (view: TGanttViews) => void;
|
||||
handleToday: () => void;
|
||||
@@ -20,7 +20,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const GanttChartHeader: React.FC<Props> = observer((props) => {
|
||||
const { blocks, fullScreenMode, handleChartView, handleToday, loaderTitle, title, toggleFullScreenMode } = props;
|
||||
const { blockIds, fullScreenMode, handleChartView, handleToday, loaderTitle, title, toggleFullScreenMode } = props;
|
||||
// chart hook
|
||||
const { currentView } = useGanttChart();
|
||||
|
||||
@@ -28,7 +28,9 @@ export const GanttChartHeader: React.FC<Props> = observer((props) => {
|
||||
<div className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2">
|
||||
<div className="flex items-center gap-2 text-lg font-medium">{title}</div>
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto text-sm font-medium">{blocks ? `${blocks.length} ${loaderTitle}` : "Loading..."}</div>
|
||||
<div className="ml-auto text-sm font-medium">
|
||||
{blockIds ? `${blockIds.length} ${loaderTitle}` : "Loading..."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { observer } from "mobx-react";
|
||||
// components
|
||||
import {
|
||||
BiWeekChartView,
|
||||
ChartDataType,
|
||||
DayChartView,
|
||||
GanttChartBlocksList,
|
||||
GanttChartSidebar,
|
||||
@@ -21,11 +22,13 @@ import { cn } from "@/helpers/common.helper";
|
||||
import { useGanttChart } from "../hooks/use-gantt-chart";
|
||||
|
||||
type Props = {
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockIds: string[];
|
||||
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
|
||||
canLoadMoreBlocks?: boolean;
|
||||
loadMoreBlocks?: () => void;
|
||||
blockToRender: (data: any) => React.ReactNode;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
bottomSpacing: boolean;
|
||||
chartBlocks: IGanttBlock[] | null;
|
||||
enableBlockLeftResize: boolean;
|
||||
enableBlockMove: boolean;
|
||||
enableBlockRightResize: boolean;
|
||||
@@ -41,11 +44,12 @@ type Props = {
|
||||
|
||||
export const GanttChartMainContent: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
blocks,
|
||||
blockIds,
|
||||
getBlockById,
|
||||
loadMoreBlocks,
|
||||
blockToRender,
|
||||
blockUpdateHandler,
|
||||
bottomSpacing,
|
||||
chartBlocks,
|
||||
enableBlockLeftResize,
|
||||
enableBlockMove,
|
||||
enableBlockRightResize,
|
||||
@@ -55,6 +59,7 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
|
||||
showAllBlocks,
|
||||
sidebarToRender,
|
||||
title,
|
||||
canLoadMoreBlocks,
|
||||
updateCurrentViewRenderPayload,
|
||||
quickAdd,
|
||||
} = props;
|
||||
@@ -104,7 +109,11 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<GanttChartSidebar
|
||||
blocks={blocks}
|
||||
blockIds={blockIds}
|
||||
getBlockById={getBlockById}
|
||||
loadMoreBlocks={loadMoreBlocks}
|
||||
canLoadMoreBlocks={canLoadMoreBlocks}
|
||||
ganttContainerRef={ganttContainerRef}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableReorder={enableReorder}
|
||||
sidebarToRender={sidebarToRender}
|
||||
@@ -116,7 +125,8 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
|
||||
{currentViewData && (
|
||||
<GanttChartBlocksList
|
||||
itemsContainerWidth={itemsContainerWidth}
|
||||
blocks={chartBlocks}
|
||||
blockIds={blockIds}
|
||||
getBlockById={getBlockById}
|
||||
blockToRender={blockToRender}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
|
||||
@@ -13,17 +13,13 @@ import { currentViewDataWithView } from "../data";
|
||||
// constants
|
||||
import { useGanttChart } from "../hooks/use-gantt-chart";
|
||||
import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types";
|
||||
import {
|
||||
generateMonthChart,
|
||||
getNumberOfDaysBetweenTwoDatesInMonth,
|
||||
getMonthChartItemPositionWidthInMonth,
|
||||
} from "../views";
|
||||
import { generateMonthChart, getNumberOfDaysBetweenTwoDatesInMonth } from "../views";
|
||||
|
||||
type ChartViewRootProps = {
|
||||
border: boolean;
|
||||
title: string;
|
||||
loaderTitle: string;
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockIds: string[];
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blockToRender: (data: any) => React.ReactNode;
|
||||
sidebarToRender: (props: any) => React.ReactNode;
|
||||
@@ -34,6 +30,9 @@ type ChartViewRootProps = {
|
||||
enableAddBlock: boolean;
|
||||
bottomSpacing: boolean;
|
||||
showAllBlocks: boolean;
|
||||
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
|
||||
loadMoreBlocks?: () => void;
|
||||
canLoadMoreBlocks?: boolean;
|
||||
quickAdd?: React.JSX.Element | undefined;
|
||||
};
|
||||
|
||||
@@ -41,11 +40,14 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
|
||||
const {
|
||||
border,
|
||||
title,
|
||||
blocks = null,
|
||||
blockIds,
|
||||
getBlockById,
|
||||
loadMoreBlocks,
|
||||
loaderTitle,
|
||||
blockUpdateHandler,
|
||||
sidebarToRender,
|
||||
blockToRender,
|
||||
canLoadMoreBlocks,
|
||||
enableBlockLeftResize,
|
||||
enableBlockRightResize,
|
||||
enableBlockMove,
|
||||
@@ -58,25 +60,10 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
|
||||
// states
|
||||
const [itemsContainerWidth, setItemsContainerWidth] = useState(0);
|
||||
const [fullScreenMode, setFullScreenMode] = useState(false);
|
||||
const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null);
|
||||
// hooks
|
||||
const { currentView, currentViewData, renderView, updateCurrentView, updateCurrentViewData, updateRenderView } =
|
||||
useGanttChart();
|
||||
|
||||
// rendering the block structure
|
||||
const renderBlockStructure = (view: ChartDataType, blocks: IGanttBlock[] | null) =>
|
||||
blocks
|
||||
? blocks.map((block: IGanttBlock) => ({
|
||||
...block,
|
||||
position: getMonthChartItemPositionWidthInMonth(view, block),
|
||||
}))
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentViewData || !blocks) return;
|
||||
setChartBlocks(() => renderBlockStructure(currentViewData, blocks));
|
||||
}, [currentViewData, blocks]);
|
||||
|
||||
const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews) => {
|
||||
const selectedCurrentView: TGanttViews = view;
|
||||
const selectedCurrentViewData: ChartDataType | undefined =
|
||||
@@ -166,7 +153,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
|
||||
})}
|
||||
>
|
||||
<GanttChartHeader
|
||||
blocks={blocks}
|
||||
blockIds={blockIds}
|
||||
fullScreenMode={fullScreenMode}
|
||||
toggleFullScreenMode={() => setFullScreenMode((prevData) => !prevData)}
|
||||
handleChartView={(key) => updateCurrentViewRenderPayload(null, key)}
|
||||
@@ -175,11 +162,13 @@ export const ChartViewRoot: FC<ChartViewRootProps> = observer((props) => {
|
||||
title={title}
|
||||
/>
|
||||
<GanttChartMainContent
|
||||
blocks={blocks}
|
||||
blockIds={blockIds}
|
||||
getBlockById={getBlockById}
|
||||
loadMoreBlocks={loadMoreBlocks}
|
||||
canLoadMoreBlocks={canLoadMoreBlocks}
|
||||
blockToRender={blockToRender}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
bottomSpacing={bottomSpacing}
|
||||
chartBlocks={chartBlocks}
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
enableBlockMove={enableBlockMove}
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC } from "react";
|
||||
// components
|
||||
import { ChartViewRoot, IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart";
|
||||
import { ChartDataType, ChartViewRoot, IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart";
|
||||
// context
|
||||
import { GanttStoreProvider } from "@/components/gantt-chart/contexts";
|
||||
|
||||
@@ -8,11 +8,14 @@ type GanttChartRootProps = {
|
||||
border?: boolean;
|
||||
title: string;
|
||||
loaderTitle: string;
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockIds: string[];
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blockToRender: (data: any) => React.ReactNode;
|
||||
sidebarToRender: (props: any) => React.ReactNode;
|
||||
quickAdd?: React.JSX.Element | undefined;
|
||||
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
|
||||
canLoadMoreBlocks?: boolean;
|
||||
loadMoreBlocks?: () => void;
|
||||
enableBlockLeftResize?: boolean;
|
||||
enableBlockRightResize?: boolean;
|
||||
enableBlockMove?: boolean;
|
||||
@@ -26,11 +29,14 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
|
||||
const {
|
||||
border = true,
|
||||
title,
|
||||
blocks,
|
||||
blockIds,
|
||||
loaderTitle = "blocks",
|
||||
blockUpdateHandler,
|
||||
sidebarToRender,
|
||||
blockToRender,
|
||||
getBlockById,
|
||||
loadMoreBlocks,
|
||||
canLoadMoreBlocks,
|
||||
enableBlockLeftResize = false,
|
||||
enableBlockRightResize = false,
|
||||
enableBlockMove = false,
|
||||
@@ -46,7 +52,10 @@ export const GanttChartRoot: FC<GanttChartRootProps> = (props) => {
|
||||
<ChartViewRoot
|
||||
border={border}
|
||||
title={title}
|
||||
blocks={blocks}
|
||||
blockIds={blockIds}
|
||||
getBlockById={getBlockById}
|
||||
loadMoreBlocks={loadMoreBlocks}
|
||||
canLoadMoreBlocks={canLoadMoreBlocks}
|
||||
loaderTitle={loaderTitle}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
sidebarToRender={sidebarToRender}
|
||||
|
||||
@@ -2,22 +2,23 @@ import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart/types";
|
||||
import { ChartDataType, IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types";
|
||||
import { CyclesSidebarBlock } from "./block";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blocks: IGanttBlock[] | null;
|
||||
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
|
||||
blockIds: string[];
|
||||
enableReorder: boolean;
|
||||
};
|
||||
|
||||
export const CycleGanttSidebar: React.FC<Props> = (props) => {
|
||||
const { blockUpdateHandler, blocks, enableReorder } = props;
|
||||
const { blockUpdateHandler, blockIds, getBlockById, enableReorder } = props;
|
||||
|
||||
const handleOrderChange = (result: DropResult) => {
|
||||
if (!blocks) return;
|
||||
if (!blockIds) return;
|
||||
|
||||
const { source, destination } = result;
|
||||
|
||||
@@ -27,29 +28,30 @@ export const CycleGanttSidebar: React.FC<Props> = (props) => {
|
||||
// return if dropped on the same index
|
||||
if (source.index === destination.index) return;
|
||||
|
||||
let updatedSortOrder = blocks[source.index].sort_order;
|
||||
let updatedSortOrder = getBlockById(blockIds[source.index]).sort_order;
|
||||
|
||||
// update the sort order to the lowest if dropped at the top
|
||||
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
|
||||
if (destination.index === 0) updatedSortOrder = getBlockById(blockIds[0]).sort_order - 1000;
|
||||
// update the sort order to the highest if dropped at the bottom
|
||||
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
|
||||
else if (destination.index === blockIds.length - 1)
|
||||
updatedSortOrder = getBlockById(blockIds[blockIds.length - 1]).sort_order + 1000;
|
||||
// update the sort order to the average of the two adjacent blocks if dropped in between
|
||||
else {
|
||||
const destinationSortingOrder = blocks[destination.index].sort_order;
|
||||
const destinationSortingOrder = getBlockById(blockIds[destination.index]).sort_order;
|
||||
const relativeDestinationSortingOrder =
|
||||
source.index < destination.index
|
||||
? blocks[destination.index + 1].sort_order
|
||||
: blocks[destination.index - 1].sort_order;
|
||||
? getBlockById(blockIds[destination.index + 1]).sort_order
|
||||
: getBlockById(blockIds[destination.index - 1]).sort_order;
|
||||
|
||||
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
|
||||
}
|
||||
|
||||
// extract the element from the source index and insert it at the destination index without updating the entire array
|
||||
const removedElement = blocks.splice(source.index, 1)[0];
|
||||
blocks.splice(destination.index, 0, removedElement);
|
||||
const removedElement = blockIds.splice(source.index, 1)[0];
|
||||
blockIds.splice(destination.index, 0, removedElement);
|
||||
|
||||
// call the block update handler with the updated sort order, new and old index
|
||||
blockUpdateHandler(removedElement.data, {
|
||||
blockUpdateHandler(getBlockById(removedElement).data, {
|
||||
sort_order: {
|
||||
destinationIndex: destination.index,
|
||||
newSortOrder: updatedSortOrder,
|
||||
@@ -64,24 +66,28 @@ export const CycleGanttSidebar: React.FC<Props> = (props) => {
|
||||
{(droppableProvided) => (
|
||||
<div className="h-full" ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
|
||||
<>
|
||||
{blocks ? (
|
||||
blocks.map((block, index) => (
|
||||
<Draggable
|
||||
key={`sidebar-block-${block.id}`}
|
||||
draggableId={`sidebar-block-${block.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<CyclesSidebarBlock
|
||||
block={block}
|
||||
enableReorder={enableReorder}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))
|
||||
{blockIds ? (
|
||||
blockIds.map((blockId, index) => {
|
||||
const block = getBlockById(blockId);
|
||||
if (!block.start_date || !block.target_date) return null;
|
||||
return (
|
||||
<Draggable
|
||||
key={`sidebar-block-${block.id}`}
|
||||
draggableId={`sidebar-block-${block.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<CyclesSidebarBlock
|
||||
block={block}
|
||||
enableReorder={enableReorder}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Loader className="space-y-3 pr-2">
|
||||
<Loader.Item height="34px" />
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Draggable } from "@hello-pangea/dnd";
|
||||
import { observer } from "mobx-react";
|
||||
import { IGanttBlock } from "@/components/gantt-chart/types";
|
||||
import { IssuesSidebarBlock } from "./block";
|
||||
|
||||
interface Props {
|
||||
blockId: string;
|
||||
enableReorder: boolean;
|
||||
index: number;
|
||||
showAllBlocks: boolean;
|
||||
getBlockById: (blockId: string) => IGanttBlock;
|
||||
}
|
||||
export const IssueDraggableBlock = observer((props: Props) => {
|
||||
const { blockId, enableReorder, index, showAllBlocks, getBlockById } = props;
|
||||
const block = getBlockById(blockId);
|
||||
|
||||
const isBlockVisibleOnSidebar = block.start_date && block.target_date;
|
||||
|
||||
// hide the block if it doesn't have start and target dates and showAllBlocks is false
|
||||
if (!showAllBlocks && !isBlockVisibleOnSidebar) return null;
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={`sidebar-block-${blockId}`}
|
||||
draggableId={`sidebar-block-${blockId}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<IssuesSidebarBlock block={block} enableReorder={enableReorder} provided={provided} snapshot={snapshot} />
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
});
|
||||
@@ -1,23 +1,44 @@
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
|
||||
import { RefObject, useRef } from "react";
|
||||
import { DragDropContext, Droppable, DropResult } from "@hello-pangea/dnd";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// types
|
||||
import { IGanttBlock, IBlockUpdateData } from "@/components/gantt-chart/types";
|
||||
import { IssuesSidebarBlock } from "./block";
|
||||
//hooks
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
import { IssueDraggableBlock } from "./issue-draggable-block";
|
||||
|
||||
type Props = {
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blocks: IGanttBlock[] | null;
|
||||
getBlockById: (id: string) => IGanttBlock;
|
||||
canLoadMoreBlocks?: boolean;
|
||||
loadMoreBlocks?: () => void;
|
||||
ganttContainerRef: RefObject<HTMLDivElement>;
|
||||
blockIds: string[];
|
||||
enableReorder: boolean;
|
||||
showAllBlocks?: boolean;
|
||||
};
|
||||
|
||||
export const IssueGanttSidebar: React.FC<Props> = (props) => {
|
||||
const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props;
|
||||
export const IssueGanttSidebar: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
blockUpdateHandler,
|
||||
blockIds,
|
||||
getBlockById,
|
||||
enableReorder,
|
||||
loadMoreBlocks,
|
||||
canLoadMoreBlocks,
|
||||
ganttContainerRef,
|
||||
showAllBlocks = false,
|
||||
} = props;
|
||||
|
||||
const intersectionRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
useIntersectionObserver(ganttContainerRef, intersectionRef, loadMoreBlocks, "50% 0% 50% 0%");
|
||||
|
||||
const handleOrderChange = (result: DropResult) => {
|
||||
if (!blocks) return;
|
||||
if (!blockIds) return;
|
||||
|
||||
const { source, destination } = result;
|
||||
|
||||
@@ -27,29 +48,30 @@ export const IssueGanttSidebar: React.FC<Props> = (props) => {
|
||||
// return if dropped on the same index
|
||||
if (source.index === destination.index) return;
|
||||
|
||||
let updatedSortOrder = blocks[source.index].sort_order;
|
||||
let updatedSortOrder = getBlockById(blockIds[source.index]).sort_order;
|
||||
|
||||
// update the sort order to the lowest if dropped at the top
|
||||
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
|
||||
if (destination.index === 0) updatedSortOrder = getBlockById(blockIds[0]).sort_order - 1000;
|
||||
// update the sort order to the highest if dropped at the bottom
|
||||
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
|
||||
else if (destination.index === blockIds.length - 1)
|
||||
updatedSortOrder = getBlockById(blockIds[blockIds.length - 1])!.sort_order + 1000;
|
||||
// update the sort order to the average of the two adjacent blocks if dropped in between
|
||||
else {
|
||||
const destinationSortingOrder = blocks[destination.index].sort_order;
|
||||
const destinationSortingOrder = getBlockById(blockIds[destination.index]).sort_order;
|
||||
const relativeDestinationSortingOrder =
|
||||
source.index < destination.index
|
||||
? blocks[destination.index + 1].sort_order
|
||||
: blocks[destination.index - 1].sort_order;
|
||||
? getBlockById(blockIds[destination.index + 1]).sort_order
|
||||
: getBlockById(blockIds[destination.index - 1]).sort_order;
|
||||
|
||||
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
|
||||
}
|
||||
|
||||
// extract the element from the source index and insert it at the destination index without updating the entire array
|
||||
const removedElement = blocks.splice(source.index, 1)[0];
|
||||
blocks.splice(destination.index, 0, removedElement);
|
||||
const removedElement = blockIds.splice(source.index, 1)[0];
|
||||
blockIds.splice(destination.index, 0, removedElement);
|
||||
|
||||
// call the block update handler with the updated sort order, new and old index
|
||||
blockUpdateHandler(removedElement.data, {
|
||||
blockUpdateHandler(getBlockById(removedElement).data, {
|
||||
sort_order: {
|
||||
destinationIndex: destination.index,
|
||||
newSortOrder: updatedSortOrder,
|
||||
@@ -64,31 +86,22 @@ export const IssueGanttSidebar: React.FC<Props> = (props) => {
|
||||
{(droppableProvided) => (
|
||||
<div ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
|
||||
<>
|
||||
{blocks ? (
|
||||
blocks.map((block, index) => {
|
||||
const isBlockVisibleOnSidebar = block.start_date && block.target_date;
|
||||
|
||||
// hide the block if it doesn't have start and target dates and showAllBlocks is false
|
||||
if (!showAllBlocks && !isBlockVisibleOnSidebar) return;
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={`sidebar-block-${block.id}`}
|
||||
draggableId={`sidebar-block-${block.id}`}
|
||||
{blockIds ? (
|
||||
<>
|
||||
{blockIds.map((blockId, index) => (
|
||||
<IssueDraggableBlock
|
||||
key={blockId}
|
||||
blockId={blockId}
|
||||
enableReorder={enableReorder}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<IssuesSidebarBlock
|
||||
block={block}
|
||||
enableReorder={enableReorder}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})
|
||||
showAllBlocks={showAllBlocks}
|
||||
getBlockById={getBlockById}
|
||||
/>
|
||||
))}
|
||||
{canLoadMoreBlocks && (
|
||||
<span ref={intersectionRef} className="h-5 w-10 bg-custom-background-80 rounded animate-pulse" />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Loader className="space-y-3 pr-2">
|
||||
<Loader.Item height="34px" />
|
||||
@@ -104,4 +117,4 @@ export const IssueGanttSidebar: React.FC<Props> = (props) => {
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2,22 +2,23 @@ import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart";
|
||||
import { ChartDataType, IBlockUpdateData, IGanttBlock } from "components/gantt-chart";
|
||||
import { ModulesSidebarBlock } from "./block";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blocks: IGanttBlock[] | null;
|
||||
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
|
||||
blockIds: string[];
|
||||
enableReorder: boolean;
|
||||
};
|
||||
|
||||
export const ModuleGanttSidebar: React.FC<Props> = (props) => {
|
||||
const { blockUpdateHandler, blocks, enableReorder } = props;
|
||||
const { blockUpdateHandler, blockIds, getBlockById, enableReorder } = props;
|
||||
|
||||
const handleOrderChange = (result: DropResult) => {
|
||||
if (!blocks) return;
|
||||
if (!blockIds) return;
|
||||
|
||||
const { source, destination } = result;
|
||||
|
||||
@@ -27,29 +28,30 @@ export const ModuleGanttSidebar: React.FC<Props> = (props) => {
|
||||
// return if dropped on the same index
|
||||
if (source.index === destination.index) return;
|
||||
|
||||
let updatedSortOrder = blocks[source.index].sort_order;
|
||||
let updatedSortOrder = getBlockById(blockIds[source.index]).sort_order;
|
||||
|
||||
// update the sort order to the lowest if dropped at the top
|
||||
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
|
||||
if (destination.index === 0) updatedSortOrder = getBlockById(blockIds[0]).sort_order - 1000;
|
||||
// update the sort order to the highest if dropped at the bottom
|
||||
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
|
||||
else if (destination.index === blockIds.length - 1)
|
||||
updatedSortOrder = getBlockById(blockIds[blockIds.length - 1]).sort_order + 1000;
|
||||
// update the sort order to the average of the two adjacent blocks if dropped in between
|
||||
else {
|
||||
const destinationSortingOrder = blocks[destination.index].sort_order;
|
||||
const destinationSortingOrder = getBlockById(blockIds[destination.index]).sort_order;
|
||||
const relativeDestinationSortingOrder =
|
||||
source.index < destination.index
|
||||
? blocks[destination.index + 1].sort_order
|
||||
: blocks[destination.index - 1].sort_order;
|
||||
? getBlockById(blockIds[destination.index + 1]).sort_order
|
||||
: getBlockById(blockIds[destination.index - 1]).sort_order;
|
||||
|
||||
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
|
||||
}
|
||||
|
||||
// extract the element from the source index and insert it at the destination index without updating the entire array
|
||||
const removedElement = blocks.splice(source.index, 1)[0];
|
||||
blocks.splice(destination.index, 0, removedElement);
|
||||
const removedElement = blockIds.splice(source.index, 1)[0];
|
||||
blockIds.splice(destination.index, 0, removedElement);
|
||||
|
||||
// call the block update handler with the updated sort order, new and old index
|
||||
blockUpdateHandler(removedElement.data, {
|
||||
blockUpdateHandler(getBlockById(removedElement).data, {
|
||||
sort_order: {
|
||||
destinationIndex: destination.index,
|
||||
newSortOrder: updatedSortOrder,
|
||||
@@ -64,24 +66,27 @@ export const ModuleGanttSidebar: React.FC<Props> = (props) => {
|
||||
{(droppableProvided) => (
|
||||
<div className="h-full" ref={droppableProvided.innerRef} {...droppableProvided.droppableProps}>
|
||||
<>
|
||||
{blocks ? (
|
||||
blocks.map((block, index) => (
|
||||
<Draggable
|
||||
key={`sidebar-block-${block.id}`}
|
||||
draggableId={`sidebar-block-${block.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<ModulesSidebarBlock
|
||||
block={block}
|
||||
enableReorder={enableReorder}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))
|
||||
{blockIds ? (
|
||||
blockIds.map((blockId, index) => {
|
||||
const block = getBlockById(blockId);
|
||||
return (
|
||||
<Draggable
|
||||
key={`sidebar-block-${block.id}`}
|
||||
draggableId={`sidebar-block-${block.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<ModulesSidebarBlock
|
||||
block={block}
|
||||
enableReorder={enableReorder}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Loader className="space-y-3 pr-2">
|
||||
<Loader.Item height="34px" />
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
import { RefObject } from "react";
|
||||
// components
|
||||
import { IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart";
|
||||
import { ChartDataType, IBlockUpdateData, IGanttBlock } from "components/gantt-chart";
|
||||
// constants
|
||||
import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "../constants";
|
||||
|
||||
type Props = {
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockIds: string[];
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
canLoadMoreBlocks?: boolean;
|
||||
loadMoreBlocks?: () => void;
|
||||
ganttContainerRef: RefObject<HTMLDivElement>;
|
||||
enableReorder: boolean;
|
||||
sidebarToRender: (props: any) => React.ReactNode;
|
||||
title: string;
|
||||
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
|
||||
quickAdd?: React.JSX.Element | undefined;
|
||||
};
|
||||
|
||||
export const GanttChartSidebar: React.FC<Props> = (props) => {
|
||||
const { blocks, blockUpdateHandler, enableReorder, sidebarToRender, title, quickAdd } = props;
|
||||
const {
|
||||
blockIds,
|
||||
blockUpdateHandler,
|
||||
enableReorder,
|
||||
sidebarToRender,
|
||||
getBlockById,
|
||||
loadMoreBlocks,
|
||||
canLoadMoreBlocks,
|
||||
ganttContainerRef,
|
||||
title,
|
||||
quickAdd,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -35,7 +51,17 @@ export const GanttChartSidebar: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
|
||||
<div className="min-h-full h-max bg-custom-background-100 overflow-x-hidden overflow-y-auto">
|
||||
{sidebarToRender && sidebarToRender({ title, blockUpdateHandler, blocks, enableReorder })}
|
||||
{sidebarToRender &&
|
||||
sidebarToRender({
|
||||
title,
|
||||
blockUpdateHandler,
|
||||
blockIds,
|
||||
getBlockById,
|
||||
enableReorder,
|
||||
canLoadMoreBlocks,
|
||||
ganttContainerRef,
|
||||
loadMoreBlocks,
|
||||
})}
|
||||
</div>
|
||||
{quickAdd ? quickAdd : null}
|
||||
</div>
|
||||
|
||||
@@ -2,19 +2,26 @@ import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
// components
|
||||
import { ArrowRight, Plus, PanelRight } from "lucide-react";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/ui";
|
||||
//components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
import { ProjectLogo } from "@/components/project";
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
// constants
|
||||
import {
|
||||
EIssueFilterType,
|
||||
EIssueLayoutTypes,
|
||||
EIssuesStoreType,
|
||||
ISSUE_DISPLAY_FILTERS_BY_LAYOUT,
|
||||
} from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { truncateText } from "@/helpers/string.helper";
|
||||
//hooks
|
||||
import {
|
||||
useApplication,
|
||||
useEventTracker,
|
||||
@@ -27,12 +34,12 @@ import {
|
||||
useIssues,
|
||||
} from "@/hooks/store";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// ui
|
||||
// icons
|
||||
// helpers
|
||||
// types
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// constants
|
||||
|
||||
|
||||
|
||||
const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => {
|
||||
// router
|
||||
@@ -96,7 +103,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
};
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId);
|
||||
},
|
||||
@@ -236,7 +243,13 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-2 ">
|
||||
<LayoutSelection
|
||||
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||
layouts={[
|
||||
EIssueLayoutTypes.LIST,
|
||||
EIssueLayoutTypes.KANBAN,
|
||||
EIssueLayoutTypes.CALENDAR,
|
||||
EIssueLayoutTypes.SPREADSHEET,
|
||||
EIssueLayoutTypes.GANTT,
|
||||
]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
|
||||
@@ -2,18 +2,27 @@ import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
import { ArrowRight, PanelRight, Plus } from "lucide-react";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
import { ProjectLogo } from "@/components/project";
|
||||
import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
// constants
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
EIssueFilterType,
|
||||
ISSUE_DISPLAY_FILTERS_BY_LAYOUT,
|
||||
EIssueLayoutTypes,
|
||||
} from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { truncateText } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import {
|
||||
useApplication,
|
||||
useEventTracker,
|
||||
@@ -27,13 +36,9 @@ import {
|
||||
} from "@/hooks/store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// components
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// ui
|
||||
// icons
|
||||
// helpers
|
||||
// types
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// constants
|
||||
|
||||
const ModuleDropdownOption: React.FC<{ moduleId: string }> = ({ moduleId }) => {
|
||||
// router
|
||||
@@ -97,11 +102,11 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
if (!projectId) return;
|
||||
updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
|
||||
},
|
||||
[projectId, moduleId, updateFilters]
|
||||
[projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
@@ -122,7 +127,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
|
||||
updateFilters(projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues });
|
||||
},
|
||||
[projectId, moduleId, issueFilters, updateFilters]
|
||||
[projectId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
@@ -130,7 +135,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
if (!projectId) return;
|
||||
updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter);
|
||||
},
|
||||
[projectId, moduleId, updateFilters]
|
||||
[projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
@@ -138,7 +143,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
if (!projectId) return;
|
||||
updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property);
|
||||
},
|
||||
[projectId, moduleId, updateFilters]
|
||||
[projectId, updateFilters]
|
||||
);
|
||||
|
||||
// derived values
|
||||
@@ -238,7 +243,13 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:flex gap-2">
|
||||
<LayoutSelection
|
||||
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||
layouts={[
|
||||
EIssueLayoutTypes.LIST,
|
||||
EIssueLayoutTypes.KANBAN,
|
||||
EIssueLayoutTypes.CALENDAR,
|
||||
EIssueLayoutTypes.SPREADSHEET,
|
||||
EIssueLayoutTypes.GANTT,
|
||||
]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC, useCallback } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
// hooks
|
||||
// components
|
||||
import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui";
|
||||
@@ -11,7 +11,7 @@ import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelect
|
||||
// ui
|
||||
// helper
|
||||
import { ProjectLogo } from "@/components/project";
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
@@ -54,7 +54,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
|
||||
);
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
|
||||
},
|
||||
@@ -127,7 +127,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<LayoutSelection
|
||||
layouts={["list", "kanban"]}
|
||||
layouts={[EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
|
||||
@@ -2,14 +2,14 @@ import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
// hooks
|
||||
import { Breadcrumbs, Button, LayersIcon, Tooltip } from "@plane/ui";
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
import { ProjectLogo } from "@/components/project";
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import {
|
||||
useApplication,
|
||||
@@ -76,7 +76,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
||||
);
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
|
||||
},
|
||||
@@ -178,7 +178,13 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
||||
</div>
|
||||
<div className="items-center gap-2 hidden md:flex">
|
||||
<LayoutSelection
|
||||
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||
layouts={[
|
||||
EIssueLayoutTypes.LIST,
|
||||
EIssueLayoutTypes.KANBAN,
|
||||
EIssueLayoutTypes.CALENDAR,
|
||||
EIssueLayoutTypes.SPREADSHEET,
|
||||
EIssueLayoutTypes.GANTT,
|
||||
]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Plus } from "lucide-react";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
// hooks
|
||||
// components
|
||||
// ui
|
||||
@@ -14,7 +14,7 @@ import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelect
|
||||
// types
|
||||
// constants
|
||||
import { ProjectLogo } from "@/components/project";
|
||||
import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, EIssueLayoutTypes } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { truncateText } from "@/helpers/string.helper";
|
||||
import {
|
||||
@@ -55,7 +55,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
if (!workspaceSlug || !projectId || !viewId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
@@ -195,7 +195,13 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutSelection
|
||||
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||
layouts={[
|
||||
EIssueLayoutTypes.LIST,
|
||||
EIssueLayoutTypes.KANBAN,
|
||||
EIssueLayoutTypes.CALENDAR,
|
||||
EIssueLayoutTypes.SPREADSHEET,
|
||||
EIssueLayoutTypes.GANTT,
|
||||
]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
|
||||
@@ -73,7 +73,7 @@ export const DeleteInboxIssueModal: React.FC<Props> = observer(({ isOpen, onClos
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Are you sure you want to delete issue{" "}
|
||||
<span className="break-words font-medium text-custom-text-100">
|
||||
{getProjectById(data?.project_id)?.identifier}-{data?.sequence_id}
|
||||
{getProjectById(data?.project_id ?? "")?.identifier}-{data?.sequence_id}
|
||||
</span>
|
||||
{""}? The issue will only be deleted from the inbox and this action cannot be undone.
|
||||
</p>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
import { Search } from "lucide-react";
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
// icons
|
||||
// components
|
||||
// types
|
||||
import { ISearchIssueResponse } from "@plane/types";
|
||||
// ui
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { Button, Loader, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// services
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { PROJECT_ISSUES_LIST } from "@/constants/fetch-keys";
|
||||
import { useProject, useProjectState } from "@/hooks/store";
|
||||
import { IssueService } from "@/services/issue";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
import useDebounce from "@/hooks/use-debounce";
|
||||
// services
|
||||
import { ProjectService } from "@/services/project";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
@@ -23,7 +24,7 @@ type Props = {
|
||||
onSubmit: (issueId: string) => void;
|
||||
};
|
||||
|
||||
const issueService = new IssueService();
|
||||
const projectService = new ProjectService();
|
||||
|
||||
export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
|
||||
const { isOpen, onClose, onSubmit, value } = props;
|
||||
@@ -35,18 +36,27 @@ export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
// hooks
|
||||
const { getProjectStates } = useProjectState();
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
issueService
|
||||
.getIssues(workspaceSlug as string, projectId as string)
|
||||
.then((res) => Object.values(res ?? {}).filter((issue) => issue.id !== issueId))
|
||||
: null
|
||||
);
|
||||
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const debouncedSearchTerm: string = useDebounce(query, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !workspaceSlug || !projectId) return;
|
||||
|
||||
setIsSearching(true);
|
||||
projectService
|
||||
.projectIssuesSearch(workspaceSlug.toString(), projectId.toString(), {
|
||||
search: debouncedSearchTerm,
|
||||
workspace_search: false,
|
||||
})
|
||||
.then((res: ISearchIssueResponse[]) => setIssues(res))
|
||||
.finally(() => setIsSearching(false));
|
||||
}, [debouncedSearchTerm, isOpen, projectId, workspaceSlug]);
|
||||
|
||||
const filteredIssues = issues.filter((issue) => issue.id !== issueId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
@@ -69,7 +79,52 @@ export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const filteredIssues = (query === "" ? issues : issues?.filter((issue) => issue.name.includes(query))) ?? [];
|
||||
const issueList =
|
||||
filteredIssues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && <h2 className="mb-2 mt-4 px-3 text-xs font-semibold text-custom-text-100">Select issue</h2>}
|
||||
<ul className="text-sm text-custom-text-100">
|
||||
{filteredIssues.map((issue) => {
|
||||
const stateColor = issue.state__color || "";
|
||||
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="div"
|
||||
value={issue.id}
|
||||
className={({ active, selected }) =>
|
||||
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
|
||||
active || selected ? "bg-custom-background-80 text-custom-text-100" : ""
|
||||
} `
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: stateColor,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span className="text-custom-text-200">{issue.name}</span>
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
|
||||
<EmptyState
|
||||
type={
|
||||
query === "" ? EmptyStateType.ISSUE_RELATION_EMPTY_STATE : EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE
|
||||
}
|
||||
layout="screen-simple"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||
@@ -122,56 +177,15 @@ export const SelectDuplicateInboxIssueModal: React.FC<Props> = (props) => {
|
||||
static
|
||||
className="max-h-80 scroll-py-2 divide-y divide-custom-border-200 overflow-y-auto"
|
||||
>
|
||||
{filteredIssues.length > 0 ? (
|
||||
<li className="p-2">
|
||||
{query === "" && (
|
||||
<h2 className="mb-2 mt-4 px-3 text-xs font-semibold text-custom-text-100">Select issue</h2>
|
||||
)}
|
||||
<ul className="text-sm text-custom-text-100">
|
||||
{filteredIssues.map((issue) => {
|
||||
const stateColor =
|
||||
getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id)
|
||||
?.color || "";
|
||||
|
||||
return (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="div"
|
||||
value={issue.id}
|
||||
className={({ active, selected }) =>
|
||||
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
|
||||
active || selected ? "bg-custom-background-80 text-custom-text-100" : ""
|
||||
} `
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: stateColor,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span className="text-custom-text-200">{issue.name}</span>
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
{isSearching ? (
|
||||
<Loader className="space-y-3 p-3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
|
||||
<EmptyState
|
||||
type={
|
||||
query === ""
|
||||
? EmptyStateType.ISSUE_RELATION_EMPTY_STATE
|
||||
: EmptyStateType.ISSUE_RELATION_SEARCH_EMPTY_STATE
|
||||
}
|
||||
layout="screen-simple"
|
||||
/>
|
||||
</div>
|
||||
<>{issueList}</>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
|
||||
@@ -85,7 +85,7 @@ export const InboxIssueListItem: FC<TInboxIssueListItem> = observer((props) => {
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`} isMobile={isMobile}>
|
||||
<PriorityIcon priority={issue.priority ?? null} className="h-3.5 w-3.5" />
|
||||
<PriorityIcon priority={issue.priority} className="h-3.5 w-3.5" />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
tooltipHeading="Created on"
|
||||
|
||||
@@ -46,7 +46,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
}, [isSubmitting, setShowAlert, setIsSubmitting]);
|
||||
|
||||
const issue = issueId ? getIssueById(issueId) : undefined;
|
||||
if (!issue) return <></>;
|
||||
if (!issue || !issue.project_id) return <></>;
|
||||
|
||||
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||
}, [isSubmitting, setShowAlert, setIsSubmitting]);
|
||||
|
||||
const issue = issueId ? getIssueById(issueId) : undefined;
|
||||
if (!issue) return <></>;
|
||||
if (!issue || !issue.project_id) return <></>;
|
||||
|
||||
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export const IssueParentSiblings: FC<TIssueParentSiblings> = (props) => {
|
||||
? `ISSUE_PARENT_CHILD_ISSUES_${peekIssue?.workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}`
|
||||
: null,
|
||||
peekIssue && parentIssue && parentIssue.project_id
|
||||
? () => fetchSubIssues(peekIssue?.workspaceSlug, parentIssue.project_id, parentIssue.id)
|
||||
? () => fetchSubIssues(peekIssue?.workspaceSlug, parentIssue.project_id!, parentIssue.id)
|
||||
: null
|
||||
);
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
className="group w-3/5 flex-grow"
|
||||
buttonContainerClassName="w-full text-left"
|
||||
buttonClassName={`text-sm justify-between ${
|
||||
issue?.assignee_ids.length > 0 ? "" : "text-custom-text-400"
|
||||
issue?.assignee_ids?.length > 0 ? "" : "text-custom-text-400"
|
||||
}`}
|
||||
hideIcon={issue.assignee_ids?.length === 0}
|
||||
dropdownArrow
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { FC } from "react";
|
||||
import { FC, useCallback } from "react";
|
||||
import { DragDropContext, DropResult } from "@hello-pangea/dnd";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
import { TGroupedIssues } from "@plane/types";
|
||||
// components
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { CalendarChart } from "@/components/issues";
|
||||
// hooks
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
//constants
|
||||
import { EIssuesStoreType, EIssueLayoutTypes, EIssueGroupByToServerOptions } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { useIssues, useUser } from "@/hooks/store";
|
||||
// hooks
|
||||
import { useIssues, useUser, useCalendarView } from "@/hooks/store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
// ui
|
||||
// types
|
||||
import { IssueLayoutHOC } from "../issue-layout-HOC";
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
import { handleDragDrop } from "./utils";
|
||||
|
||||
@@ -42,8 +44,18 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { issues, issuesFilter, issueMap } = useIssues(storeType);
|
||||
const { updateIssue, removeIssue, removeIssueFromView, archiveIssue, restoreIssue, updateFilters } =
|
||||
useIssuesActions(storeType);
|
||||
const {
|
||||
fetchIssues,
|
||||
fetchNextIssues,
|
||||
updateIssue,
|
||||
removeIssue,
|
||||
removeIssueFromView,
|
||||
archiveIssue,
|
||||
restoreIssue,
|
||||
updateFilters,
|
||||
} = useIssuesActions(storeType);
|
||||
|
||||
const issueCalendarView = useCalendarView();
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
@@ -51,6 +63,27 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
|
||||
|
||||
const groupedIssueIds = (issues.groupedIssueIds ?? {}) as TGroupedIssues;
|
||||
|
||||
const layout = displayFilters?.calendar?.layout ?? "month";
|
||||
const { startDate, endDate } = issueCalendarView.getStartAndEndDate(layout) ?? {};
|
||||
|
||||
useSWR(
|
||||
startDate && endDate && layout ? `ISSUE_CALENDAR_LAYOUT_${storeType}_${startDate}_${endDate}_${layout}` : null,
|
||||
startDate && endDate && layout
|
||||
? () =>
|
||||
fetchIssues("init-loader", {
|
||||
canGroup: true,
|
||||
perPageCount: layout === "month" ? 4 : 30,
|
||||
before: endDate,
|
||||
after: startDate,
|
||||
groupedBy: EIssueGroupByToServerOptions["target_date"],
|
||||
})
|
||||
: null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
|
||||
const onDragEnd = async (result: DropResult) => {
|
||||
if (!result) return;
|
||||
|
||||
@@ -79,8 +112,25 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreIssues = useCallback(
|
||||
(dateString: string) => {
|
||||
fetchNextIssues(dateString);
|
||||
},
|
||||
[fetchNextIssues]
|
||||
);
|
||||
|
||||
const getPaginationData = useCallback(
|
||||
(groupId: string | undefined) => issues?.getPaginationData(groupId, undefined),
|
||||
[issues?.getPaginationData]
|
||||
);
|
||||
|
||||
const getGroupIssueCount = useCallback(
|
||||
(groupId: string | undefined) => issues?.getGroupIssueCount(groupId, undefined, false),
|
||||
[issues?.getGroupIssueCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IssueLayoutHOC storeType={storeType} layout={EIssueLayoutTypes.CALENDAR}>
|
||||
<div className="h-full w-full overflow-hidden bg-custom-background-100 pt-4">
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<CalendarChart
|
||||
@@ -89,6 +139,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
layout={displayFilters?.calendar?.layout}
|
||||
showWeekends={displayFilters?.calendar?.show_weekends ?? false}
|
||||
issueCalendarView={issueCalendarView}
|
||||
quickActions={(issue, customActionButton, placement) => (
|
||||
<QuickActions
|
||||
customActionButton={customActionButton}
|
||||
@@ -104,6 +155,9 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
|
||||
placements={placement}
|
||||
/>
|
||||
)}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getPaginationData={getPaginationData}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
addIssuesToView={addIssuesToView}
|
||||
quickAddCallback={issues.quickAddIssue}
|
||||
viewId={viewId}
|
||||
@@ -112,6 +166,6 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
|
||||
/>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</>
|
||||
</IssueLayoutHOC>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
TIssue,
|
||||
TIssueKanbanFilters,
|
||||
TIssueMap,
|
||||
TPaginationData,
|
||||
} from "@plane/types";
|
||||
// hooks
|
||||
import { Spinner } from "@plane/ui";
|
||||
@@ -19,12 +20,12 @@ import { EUserProjectRoles } from "@/constants/project";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { useIssues, useUser } from "@/hooks/store";
|
||||
import { useCalendarView } from "@/hooks/store/use-calendar-view";
|
||||
import useSize from "@/hooks/use-window-size";
|
||||
// components
|
||||
// ui
|
||||
// types
|
||||
import { ICycleIssuesFilter } from "@/store/issue/cycle";
|
||||
import { ICalendarStore } from "@/store/issue/issue_calendar_view.store";
|
||||
import { IModuleIssuesFilter } from "@/store/issue/module";
|
||||
import { IProjectIssuesFilter } from "@/store/issue/project";
|
||||
import { IProjectViewIssuesFilter } from "@/store/issue/project-views";
|
||||
@@ -38,6 +39,10 @@ type Props = {
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
layout: "month" | "week" | undefined;
|
||||
showWeekends: boolean;
|
||||
issueCalendarView: ICalendarStore;
|
||||
loadMoreIssues: (dateString: string) => void;
|
||||
getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
|
||||
getGroupIssueCount: (groupId: string | undefined) => number | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
quickAddCallback?: (
|
||||
workspaceSlug: string,
|
||||
@@ -62,9 +67,13 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
|
||||
groupedIssueIds,
|
||||
layout,
|
||||
showWeekends,
|
||||
issueCalendarView,
|
||||
loadMoreIssues,
|
||||
quickActions,
|
||||
quickAddCallback,
|
||||
addIssuesToView,
|
||||
getPaginationData,
|
||||
getGroupIssueCount,
|
||||
viewId,
|
||||
updateFilters,
|
||||
readOnly = false,
|
||||
@@ -75,7 +84,7 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
issues: { viewFlags },
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
const issueCalendarView = useCalendarView();
|
||||
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
@@ -97,7 +106,7 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const issueIdList = groupedIssueIds ? groupedIssueIds[formattedDatePayload] : null;
|
||||
const issueIdList = groupedIssueIds ? groupedIssueIds[formattedDatePayload] : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -126,6 +135,9 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
|
||||
week={week}
|
||||
issues={issues}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getPaginationData={getPaginationData}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
enableQuickIssueCreate
|
||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||
quickActions={quickActions}
|
||||
@@ -145,6 +157,9 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
|
||||
week={issueCalendarView.allDaysOfActiveWeek}
|
||||
issues={issues}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getPaginationData={getPaginationData}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
enableQuickIssueCreate
|
||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||
quickActions={quickActions}
|
||||
@@ -167,6 +182,9 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
|
||||
date={selectedDate}
|
||||
issues={issues}
|
||||
issueIdList={issueIdList}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getPaginationData={getPaginationData}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
quickActions={quickActions}
|
||||
enableQuickIssueCreate
|
||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Droppable } from "@hello-pangea/dnd";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types";
|
||||
// types
|
||||
import { TGroupedIssues, TIssue, TIssueMap, TPaginationData } from "@plane/types";
|
||||
// components
|
||||
import { CalendarIssueBlocks, ICalendarDate } from "@/components/issues";
|
||||
// helpers
|
||||
@@ -9,7 +10,6 @@ import { MONTHS_LIST } from "@/constants/calendar";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
// constants
|
||||
// types
|
||||
import { ICycleIssuesFilter } from "@/store/issue/cycle";
|
||||
import { IModuleIssuesFilter } from "@/store/issue/module";
|
||||
import { IProjectIssuesFilter } from "@/store/issue/project";
|
||||
@@ -20,6 +20,9 @@ type Props = {
|
||||
date: ICalendarDate;
|
||||
issues: TIssueMap | undefined;
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
loadMoreIssues: (dateString: string) => void;
|
||||
getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
|
||||
getGroupIssueCount: (groupId: string | undefined) => number | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
@@ -42,6 +45,9 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
|
||||
date,
|
||||
issues,
|
||||
groupedIssueIds,
|
||||
loadMoreIssues,
|
||||
getPaginationData,
|
||||
getGroupIssueCount,
|
||||
quickActions,
|
||||
enableQuickIssueCreate,
|
||||
disableIssueCreation,
|
||||
@@ -52,14 +58,11 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
|
||||
selectedDate,
|
||||
setSelectedDate,
|
||||
} = props;
|
||||
|
||||
const calendarLayout = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.layout ?? "month";
|
||||
|
||||
const formattedDatePayload = renderFormattedPayloadDate(date.date);
|
||||
if (!formattedDatePayload) return null;
|
||||
const issueIdList = groupedIssueIds ? groupedIssueIds[formattedDatePayload] : null;
|
||||
|
||||
const totalIssues = issueIdList?.length ?? 0;
|
||||
const issueIds = groupedIssueIds?.[formattedDatePayload];
|
||||
|
||||
const isToday = date.date.toDateString() === new Date().toDateString();
|
||||
const isSelectedDate = date.date.toDateString() == selectedDate.toDateString();
|
||||
@@ -107,8 +110,11 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
|
||||
<CalendarIssueBlocks
|
||||
date={date.date}
|
||||
issues={issues}
|
||||
issueIdList={issueIdList}
|
||||
issueIdList={issueIds ?? []}
|
||||
quickActions={quickActions}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getPaginationData={getPaginationData}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
isDragDisabled={readOnly}
|
||||
addIssuesToView={addIssuesToView}
|
||||
disableIssueCreation={disableIssueCreation}
|
||||
@@ -117,6 +123,7 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
|
||||
viewId={viewId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
@@ -141,8 +148,6 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
{date.date.getDate()}
|
||||
</div>
|
||||
|
||||
{totalIssues > 0 && <div className="flex flex-shrink-0 h-1.5 w-1.5 bg-custom-primary-100 rounded mt-1" />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Draggable } from "@hello-pangea/dnd";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TIssue, TIssueMap } from "@plane/types";
|
||||
import { TIssue, TIssueMap, TPaginationData } from "@plane/types";
|
||||
// components
|
||||
import { CalendarQuickAddIssueForm, CalendarIssueBlockRoot } from "@/components/issues";
|
||||
// helpers
|
||||
@@ -12,8 +11,11 @@ import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
type Props = {
|
||||
date: Date;
|
||||
issues: TIssueMap | undefined;
|
||||
issueIdList: string[] | null;
|
||||
issueIdList: string[];
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
loadMoreIssues: (dateString: string) => void;
|
||||
getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
|
||||
getGroupIssueCount: (groupId: string | undefined) => number | undefined;
|
||||
isDragDisabled?: boolean;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
@@ -35,6 +37,9 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
|
||||
issues,
|
||||
issueIdList,
|
||||
quickActions,
|
||||
loadMoreIssues,
|
||||
getPaginationData,
|
||||
getGroupIssueCount,
|
||||
isDragDisabled = false,
|
||||
enableQuickIssueCreate,
|
||||
disableIssueCreation,
|
||||
@@ -45,16 +50,21 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
|
||||
isMobileView = false,
|
||||
} = props;
|
||||
// states
|
||||
const [showAllIssues, setShowAllIssues] = useState(false);
|
||||
|
||||
const formattedDatePayload = renderFormattedPayloadDate(date);
|
||||
const totalIssues = issueIdList?.length ?? 0;
|
||||
|
||||
if (!formattedDatePayload) return null;
|
||||
|
||||
const dayIssueCount = getGroupIssueCount(formattedDatePayload);
|
||||
const nextPageResults = getPaginationData(formattedDatePayload)?.nextPageResults;
|
||||
|
||||
const shouldLoadMore =
|
||||
nextPageResults === undefined && dayIssueCount !== undefined
|
||||
? issueIdList?.length < dayIssueCount
|
||||
: !!nextPageResults;
|
||||
|
||||
return (
|
||||
<>
|
||||
{issueIdList?.slice(0, showAllIssues || isMobileView ? issueIdList.length : 4).map((issueId, index) =>
|
||||
{issueIdList?.map((issueId, index) =>
|
||||
!isMobileView ? (
|
||||
<Draggable key={issueId} draggableId={issueId} index={index} isDragDisabled={isDragDisabled}>
|
||||
{(provided, snapshot) => (
|
||||
@@ -79,7 +89,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
|
||||
{enableQuickIssueCreate && !disableIssueCreation && !readOnly && (
|
||||
<div className="px-1 md:px-2 py-1 border-custom-border-200 border-b md:border-none">
|
||||
<div className="px-1 md:px-2 py-1 border-custom-border-200 border-b md:border-none md:hidden group-hover:block">
|
||||
<CalendarQuickAddIssueForm
|
||||
formKey="target_date"
|
||||
groupId={formattedDatePayload}
|
||||
@@ -89,18 +99,18 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
|
||||
quickAddCallback={quickAddCallback}
|
||||
addIssuesToView={addIssuesToView}
|
||||
viewId={viewId}
|
||||
onOpen={() => setShowAllIssues(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{totalIssues > 4 && (
|
||||
<div className="hidden md:flex items-center px-2.5 py-1">
|
||||
|
||||
{shouldLoadMore && (
|
||||
<div className="flex items-center px-2.5 py-1">
|
||||
<button
|
||||
type="button"
|
||||
className="w-min whitespace-nowrap rounded text-xs px-1.5 py-1 text-custom-text-400 font-medium hover:bg-custom-background-80 hover:text-custom-text-300"
|
||||
onClick={() => setShowAllIssues(!showAllIssues)}
|
||||
onClick={() => loadMoreIssues(formattedDatePayload)}
|
||||
>
|
||||
{showAllIssues ? "Hide" : totalIssues - 4 + " more"}
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types";
|
||||
import { TGroupedIssues, TIssue, TIssueMap, TPaginationData } from "@plane/types";
|
||||
// components
|
||||
import { CalendarDayTile } from "@/components/issues";
|
||||
// helpers
|
||||
@@ -18,6 +18,9 @@ type Props = {
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
week: ICalendarWeek | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
loadMoreIssues: (dateString: string) => void;
|
||||
getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
|
||||
getGroupIssueCount: (groupId: string | undefined) => number | undefined;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
quickAddCallback?: (
|
||||
@@ -39,6 +42,9 @@ export const CalendarWeekDays: React.FC<Props> = observer((props) => {
|
||||
issues,
|
||||
groupedIssueIds,
|
||||
week,
|
||||
loadMoreIssues,
|
||||
getPaginationData,
|
||||
getGroupIssueCount,
|
||||
quickActions,
|
||||
enableQuickIssueCreate,
|
||||
disableIssueCreation,
|
||||
@@ -73,6 +79,9 @@ export const CalendarWeekDays: React.FC<Props> = observer((props) => {
|
||||
date={date}
|
||||
issues={issues}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getPaginationData={getPaginationData}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
quickActions={quickActions}
|
||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||
disableIssueCreation={disableIssueCreation}
|
||||
|
||||
@@ -1,41 +1,38 @@
|
||||
import { useState } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import size from "lodash/size";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ISearchIssueResponse, TIssueLayouts } from "@plane/types";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
// types
|
||||
import { IIssueFilterOptions, ISearchIssueResponse } from "@plane/types";
|
||||
// ui
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { ExistingIssuesListModal } from "@/components/core";
|
||||
// components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// types
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
|
||||
import { useApplication, useCycle, useEventTracker, useIssues } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string | undefined;
|
||||
projectId: string | undefined;
|
||||
cycleId: string | undefined;
|
||||
activeLayout: TIssueLayouts | undefined;
|
||||
handleClearAllFilters: () => void;
|
||||
isEmptyFilters?: boolean;
|
||||
};
|
||||
|
||||
export const CycleEmptyState: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, cycleId, activeLayout, handleClearAllFilters, isEmptyFilters = false } = props;
|
||||
export const CycleEmptyState: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||
// states
|
||||
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
|
||||
// store hooks
|
||||
const { getCycleById } = useCycle();
|
||||
const { issues } = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
|
||||
const {
|
||||
commandPalette: { toggleCreateIssueModal },
|
||||
} = useApplication();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
const userFilters = issuesFilter?.issueFilters?.filters;
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleAddIssuesToCycle = async (data: ISearchIssueResponse[]) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
@@ -43,7 +40,7 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
|
||||
const issueIds = data.map((i) => i.id);
|
||||
|
||||
await issues
|
||||
.addIssueToCycle(workspaceSlug.toString(), projectId, cycleId.toString(), issueIds)
|
||||
.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds)
|
||||
.then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
@@ -59,9 +56,32 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
|
||||
})
|
||||
);
|
||||
};
|
||||
const issueFilterCount = size(
|
||||
Object.fromEntries(
|
||||
Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0)
|
||||
)
|
||||
);
|
||||
|
||||
const isCompletedCycleSnapshotAvailable = !isEmpty(cycleDetails?.progress_snapshot ?? {});
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = null;
|
||||
});
|
||||
issuesFilter.updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{
|
||||
...newFilters,
|
||||
},
|
||||
cycleId.toString()
|
||||
);
|
||||
};
|
||||
|
||||
const isEmptyFilters = issueFilterCount > 0;
|
||||
const isCompletedAndEmpty = isCompletedCycleSnapshotAvailable || cycleDetails?.status.toLowerCase() === "completed";
|
||||
|
||||
const emptyStateType = isCompletedAndEmpty
|
||||
@@ -73,10 +93,10 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
|
||||
const emptyStateSize = isEmptyFilters ? "lg" : "sm";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<ExistingIssuesListModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
isOpen={cycleIssuesListModal}
|
||||
handleClose={() => setCycleIssuesListModal(false)}
|
||||
searchParams={{ cycle: true }}
|
||||
@@ -100,6 +120,6 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export * from "./cycle";
|
||||
export * from "./global-view";
|
||||
export * from "./module";
|
||||
export * from "./project-view";
|
||||
export * from "./project-issues";
|
||||
export * from "./draft-issues";
|
||||
export * from "./archived-issues";
|
||||
33
web/components/issues/issue-layouts/empty-states/index.tsx
Normal file
33
web/components/issues/issue-layouts/empty-states/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { ProjectArchivedEmptyState } from "./archived-issues";
|
||||
import { CycleEmptyState } from "./cycle";
|
||||
import { ProjectDraftEmptyState } from "./draft-issues";
|
||||
import { GlobalViewEmptyState } from "./global-view";
|
||||
import { ModuleEmptyState } from "./module";
|
||||
import { ProjectEmptyState } from "./project-issues";
|
||||
import { ProjectViewEmptyState } from "./project-view";
|
||||
|
||||
interface Props {
|
||||
storeType: EIssuesStoreType;
|
||||
}
|
||||
|
||||
export const IssueLayoutEmptyState = (props: Props) => {
|
||||
switch (props.storeType) {
|
||||
case EIssuesStoreType.PROJECT:
|
||||
return <ProjectEmptyState />;
|
||||
case EIssuesStoreType.PROJECT_VIEW:
|
||||
return <ProjectViewEmptyState />;
|
||||
case EIssuesStoreType.ARCHIVED:
|
||||
return <ProjectArchivedEmptyState />;
|
||||
case EIssuesStoreType.CYCLE:
|
||||
return <CycleEmptyState />;
|
||||
case EIssuesStoreType.MODULE:
|
||||
return <ModuleEmptyState />;
|
||||
case EIssuesStoreType.DRAFT:
|
||||
return <ProjectDraftEmptyState />;
|
||||
case EIssuesStoreType.GLOBAL:
|
||||
return <GlobalViewEmptyState />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,39 +1,36 @@
|
||||
import { useState } from "react";
|
||||
import size from "lodash/size";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ISearchIssueResponse, TIssueLayouts } from "@plane/types";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
// types
|
||||
import { IIssueFilterOptions, ISearchIssueResponse } from "@plane/types";
|
||||
// ui
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// ui
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "@/components/core";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// types
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
|
||||
import { useApplication, useEventTracker, useIssues } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string | undefined;
|
||||
projectId: string | undefined;
|
||||
moduleId: string | undefined;
|
||||
activeLayout: TIssueLayouts | undefined;
|
||||
handleClearAllFilters: () => void;
|
||||
isEmptyFilters?: boolean;
|
||||
};
|
||||
|
||||
export const ModuleEmptyState: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, moduleId, activeLayout, handleClearAllFilters, isEmptyFilters = false } = props;
|
||||
export const ModuleEmptyState: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, moduleId } = router.query;
|
||||
// states
|
||||
const [moduleIssuesListModal, setModuleIssuesListModal] = useState(false);
|
||||
// store hooks
|
||||
const { issues } = useIssues(EIssuesStoreType.MODULE);
|
||||
const { issues, issuesFilter } = useIssues(EIssuesStoreType.MODULE);
|
||||
const {
|
||||
commandPalette: { toggleCreateIssueModal },
|
||||
} = useApplication();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
|
||||
const userFilters = issuesFilter?.issueFilters?.filters;
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleAddIssuesToModule = async (data: ISearchIssueResponse[]) => {
|
||||
if (!workspaceSlug || !projectId || !moduleId) return;
|
||||
|
||||
@@ -56,14 +53,38 @@ export const ModuleEmptyState: React.FC<Props> = observer((props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const issueFilterCount = size(
|
||||
Object.fromEntries(
|
||||
Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0)
|
||||
)
|
||||
);
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !projectId || !moduleId) return;
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = null;
|
||||
});
|
||||
issuesFilter.updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{
|
||||
...newFilters,
|
||||
},
|
||||
moduleId.toString()
|
||||
);
|
||||
};
|
||||
|
||||
const isEmptyFilters = issueFilterCount > 0;
|
||||
const emptyStateType = isEmptyFilters ? EmptyStateType.PROJECT_EMPTY_FILTER : EmptyStateType.PROJECT_MODULE_ISSUES;
|
||||
const additionalPath = activeLayout ?? "list";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<ExistingIssuesListModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
isOpen={moduleIssuesListModal}
|
||||
handleClose={() => setModuleIssuesListModal(false)}
|
||||
searchParams={{ module: moduleId != undefined ? moduleId.toString() : "" }}
|
||||
@@ -84,6 +105,6 @@ export const ModuleEmptyState: React.FC<Props> = observer((props) => {
|
||||
secondaryButtonOnClick={isEmptyFilters ? handleClearAllFilters : () => setModuleIssuesListModal(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,20 +14,22 @@ export const ProjectViewEmptyState: React.FC = observer(() => {
|
||||
const { setTrackElement } = useEventTracker();
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<EmptyState
|
||||
title="View issues will appear here"
|
||||
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
|
||||
image={emptyIssue}
|
||||
primaryButton={{
|
||||
text: "New issue",
|
||||
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
|
||||
onClick: () => {
|
||||
setTrackElement("View issue empty state");
|
||||
commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT_VIEW);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<EmptyState
|
||||
title="View issues will appear here"
|
||||
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
|
||||
image={emptyIssue}
|
||||
primaryButton={{
|
||||
text: "New issue",
|
||||
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
|
||||
onClick: () => {
|
||||
setTrackElement("View issue empty state");
|
||||
commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT_VIEW);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import React from "react";
|
||||
|
||||
// ui
|
||||
import { TIssueLayouts } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// types
|
||||
import { ISSUE_LAYOUTS } from "@/constants/issue";
|
||||
// constants
|
||||
import { EIssueLayoutTypes,ISSUE_LAYOUTS } from "@/constants/issue";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// hooks
|
||||
|
||||
type Props = {
|
||||
layouts: TIssueLayouts[];
|
||||
onChange: (layout: TIssueLayouts) => void;
|
||||
selectedLayout: TIssueLayouts | undefined;
|
||||
layouts: EIssueLayoutTypes[];
|
||||
onChange: (layout: EIssueLayoutTypes) => void;
|
||||
selectedLayout: EIssueLayoutTypes | undefined;
|
||||
};
|
||||
|
||||
export const LayoutSelection: React.FC<Props> = (props) => {
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import { TIssue, TUnGroupedIssues } from "@plane/types";
|
||||
// hooks
|
||||
import { GanttChartRoot, IBlockUpdateData, IssueGanttSidebar } from "@/components/gantt-chart";
|
||||
import useSWR from "swr";
|
||||
import { TIssue, } from "@plane/types";
|
||||
//components
|
||||
import { ChartDataType, GanttChartRoot, IBlockUpdateData, IssueGanttSidebar } from "@/components/gantt-chart";
|
||||
import { getMonthChartItemPositionWidthInMonth } from "@/components/gantt-chart/views";
|
||||
import { GanttQuickAddIssueForm, IssueGanttBlock } from "@/components/issues";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
//constants
|
||||
import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { renderIssueBlocksStructure } from "@/helpers/issue.helper";
|
||||
import { getIssueBlocksStructure } from "@/helpers/issue.helper";
|
||||
//hooks
|
||||
import { useIssues, useUser } from "@/hooks/store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
// components
|
||||
// helpers
|
||||
// types
|
||||
// constants
|
||||
|
||||
import { ALL_ISSUES } from "@/store/issue/helpers/base-issues.store";
|
||||
import { IssueLayoutHOC } from "../issue-layout-HOC";
|
||||
|
||||
type GanttStoreType =
|
||||
| EIssuesStoreType.PROJECT
|
||||
@@ -31,19 +34,42 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { issues, issuesFilter } = useIssues(storeType);
|
||||
const { updateIssue } = useIssuesActions(storeType);
|
||||
const { issues, issuesFilter, issueMap } = useIssues(storeType);
|
||||
const { fetchIssues, fetchNextIssues, updateIssue } = useIssuesActions(storeType);
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { issueMap } = useIssues();
|
||||
const appliedDisplayFilters = issuesFilter.issueFilters?.displayFilters;
|
||||
|
||||
const issueIds = (issues.groupedIssueIds ?? []) as TUnGroupedIssues;
|
||||
useSWR(`ISSUE_GANTT_LAYOUT_${storeType}`, () => fetchIssues("init-loader", { canGroup: false, perPageCount: 100 }), {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
});
|
||||
|
||||
const issuesIds = (issues.groupedIssueIds?.[ALL_ISSUES] as string[]) ?? [];
|
||||
const nextPageResults = issues.getPaginationData(undefined, undefined)?.nextPageResults;
|
||||
|
||||
const { enableIssueCreation } = issues?.viewFlags || {};
|
||||
|
||||
const issuesArray = issueIds.map((id) => issueMap?.[id]);
|
||||
const loadMoreIssues = useCallback(() => {
|
||||
fetchNextIssues();
|
||||
}, [fetchNextIssues]);
|
||||
|
||||
const getBlockById = useCallback(
|
||||
(id: string, currentViewData?: ChartDataType | undefined) => {
|
||||
const issue = issueMap[id];
|
||||
const block = getIssueBlocksStructure(issue);
|
||||
if (currentViewData) {
|
||||
return {
|
||||
...block,
|
||||
position: getMonthChartItemPositionWidthInMonth(currentViewData, block),
|
||||
};
|
||||
}
|
||||
return block;
|
||||
},
|
||||
[issueMap]
|
||||
);
|
||||
|
||||
const updateIssueBlockStructure = async (issue: TIssue, data: IBlockUpdateData) => {
|
||||
if (!workspaceSlug) return;
|
||||
@@ -57,13 +83,14 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
|
||||
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IssueLayoutHOC storeType={storeType} layout={EIssueLayoutTypes.GANTT}>
|
||||
<div className="h-full w-full">
|
||||
<GanttChartRoot
|
||||
border={false}
|
||||
title="Issues"
|
||||
loaderTitle="Issues"
|
||||
blocks={issues ? renderIssueBlocksStructure(issuesArray) : null}
|
||||
blockIds={issuesIds}
|
||||
getBlockById={getBlockById}
|
||||
blockUpdateHandler={updateIssueBlockStructure}
|
||||
blockToRender={(data: TIssue) => <IssueGanttBlock issueId={data.id} />}
|
||||
sidebarToRender={(props) => <IssueGanttSidebar {...props} showAllBlocks />}
|
||||
@@ -77,9 +104,11 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
|
||||
<GanttQuickAddIssueForm quickAddCallback={issues.quickAddIssue} viewId={viewId} />
|
||||
) : undefined
|
||||
}
|
||||
loadMoreBlocks={loadMoreIssues}
|
||||
canLoadMoreBlocks={nextPageResults}
|
||||
showAllBlocks
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</IssueLayoutHOC>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export const IssueGanttBlock: React.FC<Props> = observer((props) => {
|
||||
workspaceSlug &&
|
||||
issueDetails &&
|
||||
!issueDetails.tempId &&
|
||||
issueDetails.project_id &&
|
||||
setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id });
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
@@ -85,6 +86,7 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
|
||||
const handleIssuePeekOverview = () =>
|
||||
workspaceSlug &&
|
||||
issueDetails &&
|
||||
issueDetails.project_id &&
|
||||
setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id });
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
|
||||
51
web/components/issues/issue-layouts/issue-layout-HOC.tsx
Normal file
51
web/components/issues/issue-layouts/issue-layout-HOC.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
CalendarLayoutLoader,
|
||||
GanttLayoutLoader,
|
||||
KanbanLayoutLoader,
|
||||
ListLayoutLoader,
|
||||
SpreadsheetLayoutLoader,
|
||||
} from "@/components/ui";
|
||||
import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue";
|
||||
import { useIssues } from "@/hooks/store";
|
||||
import { IssueLayoutEmptyState } from "./empty-states";
|
||||
|
||||
const ActiveLoader = (props: { layout: EIssueLayoutTypes }) => {
|
||||
const { layout } = props;
|
||||
switch (layout) {
|
||||
case EIssueLayoutTypes.LIST:
|
||||
return <ListLayoutLoader />;
|
||||
case EIssueLayoutTypes.KANBAN:
|
||||
return <KanbanLayoutLoader />;
|
||||
case EIssueLayoutTypes.SPREADSHEET:
|
||||
return <SpreadsheetLayoutLoader />;
|
||||
case EIssueLayoutTypes.CALENDAR:
|
||||
return <CalendarLayoutLoader />;
|
||||
case EIssueLayoutTypes.GANTT:
|
||||
return <GanttLayoutLoader />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface Props {
|
||||
children: string | JSX.Element | JSX.Element[];
|
||||
storeType: EIssuesStoreType;
|
||||
layout: EIssueLayoutTypes;
|
||||
}
|
||||
|
||||
export const IssueLayoutHOC = observer((props: Props) => {
|
||||
const { storeType, layout } = props;
|
||||
|
||||
const { issues } = useIssues(storeType);
|
||||
|
||||
if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) {
|
||||
return <ActiveLoader layout={layout} />;
|
||||
}
|
||||
|
||||
if (issues.getGroupIssueCount(undefined, undefined, false) === 0) {
|
||||
return <IssueLayoutEmptyState storeType={storeType} />;
|
||||
}
|
||||
|
||||
return <>{props.children}</>;
|
||||
});
|
||||
@@ -1,18 +1,24 @@
|
||||
import { FC, useCallback, useRef, useState } from "react";
|
||||
import { DragDropContext, DragStart, DraggableLocation, DropResult, Droppable } from "@hello-pangea/dnd";
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
import { TIssue } from "@plane/types";
|
||||
// hooks
|
||||
import { Spinner, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
//ui
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
//components
|
||||
import { DeleteIssueModal } from "@/components/issues";
|
||||
//constants
|
||||
import { ISSUE_DELETED } from "@/constants/event-tracker";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
|
||||
import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
//hooks
|
||||
import { useEventTracker, useIssues, useUser } from "@/hooks/store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
// ui
|
||||
// types
|
||||
import { IssueLayoutHOC } from "../issue-layout-HOC";
|
||||
import { IQuickActionProps } from "../list/list-view-types";
|
||||
//components
|
||||
import { KanBan } from "./default";
|
||||
@@ -28,7 +34,6 @@ export type KanbanStoreType =
|
||||
| EIssuesStoreType.PROFILE;
|
||||
export interface IBaseKanBanLayout {
|
||||
QuickActions: FC<IQuickActionProps>;
|
||||
showLoader?: boolean;
|
||||
viewId?: string;
|
||||
storeType: KanbanStoreType;
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<any>;
|
||||
@@ -45,7 +50,6 @@ type KanbanDragState = {
|
||||
export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBaseKanBanLayout) => {
|
||||
const {
|
||||
QuickActions,
|
||||
showLoader,
|
||||
viewId,
|
||||
storeType,
|
||||
addIssuesToView,
|
||||
@@ -61,10 +65,16 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
} = useUser();
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
const { issueMap, issuesFilter, issues } = useIssues(storeType);
|
||||
const { updateIssue, removeIssue, removeIssueFromView, archiveIssue, restoreIssue, updateFilters } =
|
||||
useIssuesActions(storeType);
|
||||
|
||||
const issueIds = issues?.groupedIssueIds || [];
|
||||
const {
|
||||
fetchIssues,
|
||||
fetchNextIssues,
|
||||
updateIssue,
|
||||
removeIssue,
|
||||
removeIssueFromView,
|
||||
archiveIssue,
|
||||
restoreIssue,
|
||||
updateFilters,
|
||||
} = useIssuesActions(storeType);
|
||||
|
||||
const displayFilters = issuesFilter?.issueFilters?.displayFilters;
|
||||
const displayProperties = issuesFilter?.issueFilters?.displayProperties;
|
||||
@@ -72,6 +82,32 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
const sub_group_by: string | null = displayFilters?.sub_group_by || null;
|
||||
const group_by: string | null = displayFilters?.group_by || null;
|
||||
|
||||
useSWR(
|
||||
`ISSUE_KANBAN_LAYOUT_${storeType}_${group_by}_${sub_group_by}`,
|
||||
() => fetchIssues("init-loader", { canGroup: true, perPageCount: 30 }),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
|
||||
const fetchMoreIssues = useCallback(
|
||||
(groupId?: string, subgroupId?: string) => {
|
||||
if (issues.loader !== "pagination") {
|
||||
fetchNextIssues(groupId, subgroupId);
|
||||
}
|
||||
},
|
||||
[fetchNextIssues]
|
||||
);
|
||||
|
||||
const debouncedFetchMoreIssues = debounce(
|
||||
(groupId?: string, subgroupId?: string) => fetchMoreIssues(groupId, subgroupId),
|
||||
300,
|
||||
{ leading: true, trailing: false }
|
||||
);
|
||||
|
||||
const groupedIssueIds = issues?.groupedIssueIds;
|
||||
|
||||
const userDisplayFilters = displayFilters || null;
|
||||
|
||||
const KanBanView = sub_group_by ? KanBanSwimLanes : KanBan;
|
||||
@@ -136,7 +172,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
sub_group_by,
|
||||
group_by,
|
||||
issueMap,
|
||||
issueIds,
|
||||
groupedIssueIds,
|
||||
updateIssue,
|
||||
removeIssue
|
||||
).catch((err) => {
|
||||
@@ -177,7 +213,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
sub_group_by,
|
||||
group_by,
|
||||
issueMap,
|
||||
issueIds,
|
||||
groupedIssueIds,
|
||||
updateIssue,
|
||||
removeIssue
|
||||
).finally(() => {
|
||||
@@ -207,7 +243,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
const kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] };
|
||||
|
||||
return (
|
||||
<>
|
||||
<IssueLayoutHOC storeType={storeType} layout={EIssueLayoutTypes.KANBAN}>
|
||||
<DeleteIssueModal
|
||||
dataId={dragState.draggedIssueId}
|
||||
isOpen={deleteIssueModal}
|
||||
@@ -215,12 +251,6 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
onSubmit={handleDeleteIssue}
|
||||
/>
|
||||
|
||||
{showLoader && issues?.loader === "init-loader" && (
|
||||
<div className="fixed right-2 top-16 z-30 flex h-10 w-10 items-center justify-center rounded bg-custom-background-80 shadow-custom-shadow-sm">
|
||||
<Spinner className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="vertical-scrollbar horizontal-scrollbar scrollbar-lg relative flex h-full w-full overflow-auto bg-custom-background-90"
|
||||
ref={scrollableContainerRef}
|
||||
@@ -253,7 +283,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
<div className="h-max w-max">
|
||||
<KanBanView
|
||||
issuesMap={issueMap}
|
||||
issueIds={issueIds}
|
||||
groupedIssueIds={groupedIssueIds ?? {}}
|
||||
getGroupIssueCount={issues.getGroupIssueCount}
|
||||
displayProperties={displayProperties}
|
||||
sub_group_by={sub_group_by}
|
||||
group_by={group_by}
|
||||
@@ -264,6 +295,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
enableQuickIssueCreate={enableQuickAdd}
|
||||
showEmptyGroup={userDisplayFilters?.show_empty_groups ?? true}
|
||||
quickAddCallback={issues?.quickAddIssue}
|
||||
getPaginationData={issues.getPaginationData}
|
||||
viewId={viewId}
|
||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed || isCompletedCycle}
|
||||
canEditProperties={canEditProperties}
|
||||
@@ -271,11 +303,12 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
addIssuesToView={addIssuesToView}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
isDragStarted={isDragStarted}
|
||||
loadMoreIssues={debouncedFetchMoreIssues}
|
||||
/>
|
||||
</div>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</IssueLayoutHOC>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,9 @@ interface IssueBlockProps {
|
||||
isDragDisabled: boolean;
|
||||
draggableId: string;
|
||||
index: number;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
updateIssue:
|
||||
| ((projectId: string | null | undefined, issueId: string, data: Partial<TIssue>) => Promise<void>)
|
||||
| undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
@@ -34,7 +36,9 @@ interface IssueBlockProps {
|
||||
interface IssueDetailsBlockProps {
|
||||
issue: TIssue;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
updateIssue:
|
||||
| ((projectId: string | null | undefined, issueId: string, data: Partial<TIssue>) => Promise<void>)
|
||||
| undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
@@ -120,7 +124,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const canEditIssueProperties = canEditProperties(issue.project_id);
|
||||
const canEditIssueProperties = canEditProperties(issue.project_id ?? undefined);
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MutableRefObject, memo } from "react";
|
||||
import { MutableRefObject } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
//types
|
||||
import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
|
||||
import { KanbanIssueBlock } from "@/components/issues";
|
||||
@@ -12,14 +13,16 @@ interface IssueBlocksListProps {
|
||||
issueIds: string[];
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
isDragDisabled: boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
updateIssue:
|
||||
| ((projectId: string | null | undefined, issueId: string, data: Partial<TIssue>) => Promise<void>)
|
||||
| undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
isDragStarted?: boolean;
|
||||
}
|
||||
|
||||
const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
|
||||
export const KanbanIssueBlocksList: React.FC<IssueBlocksListProps> = observer((props) => {
|
||||
const {
|
||||
sub_group_id,
|
||||
columnId,
|
||||
@@ -69,6 +72,4 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const KanbanIssueBlocksList = memo(KanbanIssueBlocksListMemo);
|
||||
});
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
IIssueDisplayProperties,
|
||||
IIssueMap,
|
||||
TSubGroupedIssues,
|
||||
TUnGroupedIssues,
|
||||
TIssueKanbanFilters,
|
||||
TPaginationData,
|
||||
} from "@plane/types";
|
||||
// constants
|
||||
// hooks
|
||||
@@ -33,16 +33,25 @@ import { KanbanGroup } from "./kanban-group";
|
||||
|
||||
export interface IGroupByKanBan {
|
||||
issuesMap: IIssueMap;
|
||||
issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues;
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
sub_group_by: string | null;
|
||||
group_by: string | null;
|
||||
sub_group_id: string;
|
||||
isDragDisabled: boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
updateIssue:
|
||||
| ((projectId: string | null | undefined, issueId: string, data: Partial<TIssue>) => Promise<void>)
|
||||
| undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: any;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
quickAddCallback?: (
|
||||
workspaceSlug: string,
|
||||
@@ -64,7 +73,9 @@ export interface IGroupByKanBan {
|
||||
const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
const {
|
||||
issuesMap,
|
||||
issueIds,
|
||||
groupedIssueIds,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
displayProperties,
|
||||
sub_group_by,
|
||||
group_by,
|
||||
@@ -76,6 +87,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
handleKanbanFilters,
|
||||
enableQuickIssueCreate,
|
||||
quickAddCallback,
|
||||
loadMoreIssues,
|
||||
viewId,
|
||||
disableIssueCreation,
|
||||
storeType,
|
||||
@@ -125,7 +137,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
showIssues: true,
|
||||
};
|
||||
if (!showEmptyGroup) {
|
||||
if ((issueIds as TGroupedIssues)?.[_list.id]?.length > 0) groupVisibility.showGroup = true;
|
||||
if ((getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0) groupVisibility.showGroup = true;
|
||||
else groupVisibility.showGroup = false;
|
||||
}
|
||||
if (kanbanFilters?.group_by.includes(_list.id)) groupVisibility.showIssues = false;
|
||||
@@ -158,7 +170,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
column_id={subList.id}
|
||||
icon={subList.icon}
|
||||
title={subList.name}
|
||||
count={(issueIds as TGroupedIssues)?.[subList.id]?.length || 0}
|
||||
count={getGroupIssueCount(subList.id, undefined, false) ?? 0}
|
||||
issuePayload={subList.payload}
|
||||
disableIssueCreation={disableIssueCreation || isGroupByCreatedBy}
|
||||
storeType={storeType}
|
||||
@@ -173,7 +185,9 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
<KanbanGroup
|
||||
groupId={subList.id}
|
||||
issuesMap={issuesMap}
|
||||
issueIds={issueIds}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
peekIssueId={peekIssue?.issueId ?? ""}
|
||||
displayProperties={displayProperties}
|
||||
sub_group_by={sub_group_by}
|
||||
@@ -189,6 +203,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
canEditProperties={canEditProperties}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
isDragStarted={isDragStarted}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -200,15 +215,24 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
|
||||
export interface IKanBan {
|
||||
issuesMap: IIssueMap;
|
||||
issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues;
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
sub_group_by: string | null;
|
||||
group_by: string | null;
|
||||
sub_group_id?: string;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
updateIssue:
|
||||
| ((projectId: string | null | undefined, issueId: string, data: Partial<TIssue>) => Promise<void>)
|
||||
| undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
showEmptyGroup: boolean;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
quickAddCallback?: (
|
||||
@@ -230,7 +254,9 @@ export interface IKanBan {
|
||||
export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
const {
|
||||
issuesMap,
|
||||
issueIds,
|
||||
groupedIssueIds,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
displayProperties,
|
||||
sub_group_by,
|
||||
group_by,
|
||||
@@ -239,6 +265,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
quickActions,
|
||||
kanbanFilters,
|
||||
handleKanbanFilters,
|
||||
loadMoreIssues,
|
||||
enableQuickIssueCreate,
|
||||
quickAddCallback,
|
||||
viewId,
|
||||
@@ -257,7 +284,9 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
return (
|
||||
<GroupByKanBan
|
||||
issuesMap={issuesMap}
|
||||
issueIds={issueIds}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
displayProperties={displayProperties}
|
||||
group_by={group_by}
|
||||
sub_group_by={sub_group_by}
|
||||
@@ -267,6 +296,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
quickActions={quickActions}
|
||||
kanbanFilters={kanbanFilters}
|
||||
handleKanbanFilters={handleKanbanFilters}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MutableRefObject } from "react";
|
||||
import { MutableRefObject, useRef } from "react";
|
||||
import { Droppable } from "@hello-pangea/dnd";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import {
|
||||
TGroupedIssues,
|
||||
@@ -7,10 +8,13 @@ import {
|
||||
IIssueDisplayProperties,
|
||||
IIssueMap,
|
||||
TSubGroupedIssues,
|
||||
TUnGroupedIssues,
|
||||
TPaginationData,
|
||||
} from "@plane/types";
|
||||
import { useProjectState } from "@/hooks/store";
|
||||
//components
|
||||
import { KanbanIssueBlockLoader } from "@/components/ui/loader";
|
||||
//hooks
|
||||
import { useProjectState } from "@/hooks/store";
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
//types
|
||||
import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from ".";
|
||||
|
||||
@@ -18,13 +22,21 @@ interface IKanbanGroup {
|
||||
groupId: string;
|
||||
issuesMap: IIssueMap;
|
||||
peekIssueId?: string;
|
||||
issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues;
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
sub_group_by: string | null;
|
||||
group_by: string | null;
|
||||
sub_group_id: string;
|
||||
isDragDisabled: boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
updateIssue:
|
||||
| ((projectId: string | null | undefined, issueId: string, data: Partial<TIssue>) => Promise<void>)
|
||||
| undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
quickAddCallback?: (
|
||||
@@ -33,6 +45,7 @@ interface IKanbanGroup {
|
||||
data: TIssue,
|
||||
viewId?: string
|
||||
) => Promise<TIssue | undefined>;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
viewId?: string;
|
||||
disableIssueCreation?: boolean;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
@@ -41,7 +54,7 @@ interface IKanbanGroup {
|
||||
isDragStarted?: boolean;
|
||||
}
|
||||
|
||||
export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
export const KanbanGroup = observer((props: IKanbanGroup) => {
|
||||
const {
|
||||
groupId,
|
||||
sub_group_id,
|
||||
@@ -49,12 +62,15 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
sub_group_by,
|
||||
issuesMap,
|
||||
displayProperties,
|
||||
issueIds,
|
||||
groupedIssueIds,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
peekIssueId,
|
||||
isDragDisabled,
|
||||
updateIssue,
|
||||
quickActions,
|
||||
canEditProperties,
|
||||
loadMoreIssues,
|
||||
enableQuickIssueCreate,
|
||||
disableIssueCreation,
|
||||
quickAddCallback,
|
||||
@@ -65,6 +81,10 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
// hooks
|
||||
const projectState = useProjectState();
|
||||
|
||||
const intersectionRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
useIntersectionObserver(scrollableContainerRef, intersectionRef, loadMoreIssues, `50% 0% 50% 0%`);
|
||||
|
||||
const prePopulateQuickAddData = (
|
||||
groupByKey: string | null,
|
||||
subGroupByKey: string | null,
|
||||
@@ -117,6 +137,23 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
return preloadedData;
|
||||
};
|
||||
|
||||
const isSubGroup = !!sub_group_id && sub_group_id !== "null";
|
||||
|
||||
const issueIds = isSubGroup
|
||||
? (groupedIssueIds as TSubGroupedIssues)?.[groupId]?.[sub_group_id]
|
||||
: (groupedIssueIds as TGroupedIssues)?.[groupId];
|
||||
|
||||
if (!issueIds) return null;
|
||||
|
||||
const groupIssueCount = getGroupIssueCount(groupId, sub_group_id, false);
|
||||
|
||||
const nextPageResults = getPaginationData(groupId, sub_group_id)?.nextPageResults;
|
||||
|
||||
const shouldLoadMore =
|
||||
nextPageResults === undefined && groupIssueCount !== undefined
|
||||
? issueIds?.length < groupIssueCount
|
||||
: !!nextPageResults;
|
||||
|
||||
return (
|
||||
<div className={`relative w-full h-full transition-all`}>
|
||||
<Droppable droppableId={`${groupId}__${sub_group_id}`}>
|
||||
@@ -131,7 +168,7 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
columnId={groupId}
|
||||
issuesMap={issuesMap}
|
||||
peekIssueId={peekIssueId}
|
||||
issueIds={(issueIds as TGroupedIssues)?.[groupId] || []}
|
||||
issueIds={issueIds}
|
||||
displayProperties={displayProperties}
|
||||
isDragDisabled={isDragDisabled}
|
||||
updateIssue={updateIssue}
|
||||
@@ -143,6 +180,19 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
|
||||
{provided.placeholder}
|
||||
|
||||
{shouldLoadMore &&
|
||||
(isSubGroup ? (
|
||||
<div
|
||||
className="w-full sticky bottom-0 p-3 text-sm text-custom-primary-100 hover:underline cursor-pointer"
|
||||
onClick={() => loadMoreIssues(groupId, sub_group_id)}
|
||||
>
|
||||
{" "}
|
||||
Load more ↓
|
||||
</div>
|
||||
) : (
|
||||
<KanbanIssueBlockLoader ref={intersectionRef} />
|
||||
))}
|
||||
|
||||
{enableQuickIssueCreate && !disableIssueCreation && (
|
||||
<div className="w-full bg-custom-background-90 py-0.5 sticky bottom-0">
|
||||
<KanBanQuickAddIssueForm
|
||||
@@ -162,4 +212,4 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
</Droppable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -35,7 +35,6 @@ export const CycleKanBanLayout: React.FC = observer(() => {
|
||||
|
||||
return (
|
||||
<BaseKanBanRoot
|
||||
showLoader
|
||||
QuickActions={CycleIssueQuickActions}
|
||||
viewId={cycleId?.toString() ?? ""}
|
||||
storeType={EIssuesStoreType.CYCLE}
|
||||
|
||||
@@ -7,5 +7,5 @@ import { BaseKanBanRoot } from "../base-kanban-root";
|
||||
export interface IKanBanLayout {}
|
||||
|
||||
export const DraftKanBanLayout: React.FC = observer(() => (
|
||||
<BaseKanBanRoot showLoader QuickActions={DraftIssueQuickActions} storeType={EIssuesStoreType.DRAFT} />
|
||||
<BaseKanBanRoot QuickActions={DraftIssueQuickActions} storeType={EIssuesStoreType.DRAFT} />
|
||||
));
|
||||
|
||||
@@ -21,7 +21,6 @@ export const ModuleKanBanLayout: React.FC = observer(() => {
|
||||
|
||||
return (
|
||||
<BaseKanBanRoot
|
||||
showLoader
|
||||
QuickActions={ModuleIssueQuickActions}
|
||||
viewId={moduleId?.toString()}
|
||||
storeType={EIssuesStoreType.MODULE}
|
||||
|
||||
@@ -22,7 +22,6 @@ export const ProfileIssuesKanBanLayout: React.FC = observer(() => {
|
||||
|
||||
return (
|
||||
<BaseKanBanRoot
|
||||
showLoader
|
||||
QuickActions={ProjectIssueQuickActions}
|
||||
storeType={EIssuesStoreType.PROFILE}
|
||||
canEditPropertiesBasedOnProject={canEditPropertiesBasedOnProject}
|
||||
|
||||
@@ -8,5 +8,5 @@ import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { BaseKanBanRoot } from "../base-kanban-root";
|
||||
|
||||
export const KanBanLayout: React.FC = observer(() => (
|
||||
<BaseKanBanRoot showLoader QuickActions={ProjectIssueQuickActions} storeType={EIssuesStoreType.PROJECT} />
|
||||
<BaseKanBanRoot QuickActions={ProjectIssueQuickActions} storeType={EIssuesStoreType.PROJECT} />
|
||||
));
|
||||
|
||||
@@ -16,7 +16,6 @@ export const ProjectViewKanBanLayout: React.FC = observer(() => {
|
||||
|
||||
return (
|
||||
<BaseKanBanRoot
|
||||
showLoader
|
||||
QuickActions={ProjectIssueQuickActions}
|
||||
storeType={EIssuesStoreType.PROJECT_VIEW}
|
||||
viewId={viewId?.toString()}
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
IIssueDisplayProperties,
|
||||
IIssueMap,
|
||||
TSubGroupedIssues,
|
||||
TUnGroupedIssues,
|
||||
TIssueKanbanFilters,
|
||||
TPaginationData,
|
||||
} from "@plane/types";
|
||||
// components
|
||||
import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
|
||||
@@ -22,7 +22,11 @@ import { HeaderSubGroupByCard } from "./headers/sub-group-by-card";
|
||||
// constants
|
||||
|
||||
interface ISubGroupSwimlaneHeader {
|
||||
issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
sub_group_by: string | null;
|
||||
group_by: string | null;
|
||||
list: IGroupByColumn[];
|
||||
@@ -32,33 +36,23 @@ interface ISubGroupSwimlaneHeader {
|
||||
showEmptyGroup: boolean;
|
||||
}
|
||||
|
||||
const getSubGroupHeaderIssuesCount = (issueIds: TSubGroupedIssues, groupById: string) => {
|
||||
let headerCount = 0;
|
||||
Object.keys(issueIds).map((groupState) => {
|
||||
headerCount = headerCount + (issueIds?.[groupState]?.[groupById]?.length || 0);
|
||||
});
|
||||
return headerCount;
|
||||
};
|
||||
|
||||
const visibilitySubGroupByGroupCount = (
|
||||
issueIds: TSubGroupedIssues,
|
||||
_list: IGroupByColumn,
|
||||
subGroupIssueCount: number,
|
||||
showEmptyGroup: boolean
|
||||
): boolean => {
|
||||
let subGroupHeaderVisibility = true;
|
||||
|
||||
if (showEmptyGroup) subGroupHeaderVisibility = true;
|
||||
else {
|
||||
if (getSubGroupHeaderIssuesCount(issueIds, _list.id) > 0) subGroupHeaderVisibility = true;
|
||||
if (subGroupIssueCount > 0) subGroupHeaderVisibility = true;
|
||||
else subGroupHeaderVisibility = false;
|
||||
}
|
||||
|
||||
return subGroupHeaderVisibility;
|
||||
};
|
||||
|
||||
const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = ({
|
||||
issueIds,
|
||||
sub_group_by,
|
||||
const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = observer(({
|
||||
getGroupIssueCount, sub_group_by,
|
||||
group_by,
|
||||
storeType,
|
||||
list,
|
||||
@@ -70,9 +64,10 @@ const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = ({
|
||||
{list &&
|
||||
list.length > 0 &&
|
||||
list.map((_list: IGroupByColumn) => {
|
||||
const groupCount = getGroupIssueCount(_list?.id, undefined, false) ?? 0;
|
||||
|
||||
const subGroupByVisibilityToggle = visibilitySubGroupByGroupCount(
|
||||
issueIds as TSubGroupedIssues,
|
||||
_list,
|
||||
groupCount,
|
||||
showEmptyGroup
|
||||
);
|
||||
|
||||
@@ -86,24 +81,32 @@ const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = ({
|
||||
column_id={_list.id}
|
||||
icon={_list.icon}
|
||||
title={_list.name}
|
||||
count={getSubGroupHeaderIssuesCount(issueIds as TSubGroupedIssues, _list?.id)}
|
||||
count={groupCount}
|
||||
kanbanFilters={kanbanFilters}
|
||||
handleKanbanFilters={handleKanbanFilters}
|
||||
issuePayload={_list.payload}
|
||||
storeType={storeType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
|
||||
issuesMap: IIssueMap;
|
||||
issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues;
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
showEmptyGroup: boolean;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
updateIssue:
|
||||
| ((projectId: string | null | undefined, issueId: string, data: Partial<TIssue>) => Promise<void>)
|
||||
| undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||
@@ -121,11 +124,15 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
|
||||
) => Promise<TIssue | undefined>;
|
||||
viewId?: string;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
}
|
||||
|
||||
const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
const {
|
||||
issuesMap,
|
||||
issueIds,
|
||||
groupedIssueIds,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
sub_group_by,
|
||||
group_by,
|
||||
list,
|
||||
@@ -135,6 +142,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
displayProperties,
|
||||
kanbanFilters,
|
||||
handleKanbanFilters,
|
||||
loadMoreIssues,
|
||||
showEmptyGroup,
|
||||
enableQuickIssueCreate,
|
||||
canEditProperties,
|
||||
@@ -145,24 +153,14 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
isDragStarted,
|
||||
} = props;
|
||||
|
||||
const calculateIssueCount = (column_id: string) => {
|
||||
let issueCount = 0;
|
||||
const subGroupedIds = issueIds as TSubGroupedIssues;
|
||||
subGroupedIds?.[column_id] &&
|
||||
Object.keys(subGroupedIds?.[column_id])?.forEach((_list: any) => {
|
||||
issueCount += subGroupedIds?.[column_id]?.[_list]?.length || 0;
|
||||
});
|
||||
return issueCount;
|
||||
};
|
||||
|
||||
const visibilitySubGroupBy = (_list: IGroupByColumn): { showGroup: boolean; showIssues: boolean } => {
|
||||
const visibilitySubGroupBy = (_list: IGroupByColumn, subGroupCount: number): { showGroup: boolean; showIssues: boolean } => {
|
||||
const subGroupVisibility = {
|
||||
showGroup: true,
|
||||
showIssues: true,
|
||||
};
|
||||
if (showEmptyGroup) subGroupVisibility.showGroup = true;
|
||||
else {
|
||||
if (calculateIssueCount(_list.id) > 0) subGroupVisibility.showGroup = true;
|
||||
if (subGroupCount > 0) subGroupVisibility.showGroup = true;
|
||||
else subGroupVisibility.showGroup = false;
|
||||
}
|
||||
if (kanbanFilters?.sub_group_by.includes(_list.id)) subGroupVisibility.showIssues = false;
|
||||
@@ -174,7 +172,8 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
{list &&
|
||||
list.length > 0 &&
|
||||
list.map((_list: any) => {
|
||||
const subGroupByVisibilityToggle = visibilitySubGroupBy(_list);
|
||||
const issueCount = getGroupIssueCount(undefined, _list.id, true) ?? 0;
|
||||
const subGroupByVisibilityToggle = visibilitySubGroupBy(_list, issueCount);
|
||||
if (subGroupByVisibilityToggle.showGroup === false) return <></>;
|
||||
return (
|
||||
<div key={_list.id} className="flex flex-shrink-0 flex-col">
|
||||
@@ -184,7 +183,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
column_id={_list.id}
|
||||
icon={_list.Icon}
|
||||
title={_list.name || ""}
|
||||
count={calculateIssueCount(_list.id)}
|
||||
count={issueCount}
|
||||
kanbanFilters={kanbanFilters}
|
||||
handleKanbanFilters={handleKanbanFilters}
|
||||
/>
|
||||
@@ -196,7 +195,9 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
<div className="relative">
|
||||
<KanBan
|
||||
issuesMap={issuesMap}
|
||||
issueIds={(issueIds as TSubGroupedIssues)?.[_list.id]}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
displayProperties={displayProperties}
|
||||
sub_group_by={sub_group_by}
|
||||
group_by={group_by}
|
||||
@@ -214,9 +215,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
viewId={viewId}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
isDragStarted={isDragStarted}
|
||||
subGroupIssueHeaderCount={(groupByListId: string) =>
|
||||
getSubGroupHeaderIssuesCount(issueIds as TSubGroupedIssues, groupByListId)
|
||||
}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -229,14 +228,23 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
|
||||
export interface IKanBanSwimLanes {
|
||||
issuesMap: IIssueMap;
|
||||
issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues;
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
sub_group_by: string | null;
|
||||
group_by: string | null;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
updateIssue:
|
||||
| ((projectId: string | null | undefined, issueId: string, data: Partial<TIssue>) => Promise<void>)
|
||||
| undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
showEmptyGroup: boolean;
|
||||
isDragStarted?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
@@ -257,7 +265,9 @@ export interface IKanBanSwimLanes {
|
||||
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
const {
|
||||
issuesMap,
|
||||
issueIds,
|
||||
groupedIssueIds,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
displayProperties,
|
||||
sub_group_by,
|
||||
group_by,
|
||||
@@ -266,6 +276,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
quickActions,
|
||||
kanbanFilters,
|
||||
handleKanbanFilters,
|
||||
loadMoreIssues,
|
||||
showEmptyGroup,
|
||||
isDragStarted,
|
||||
disableIssueCreation,
|
||||
@@ -313,7 +324,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
<div className="relative">
|
||||
<div className="sticky top-0 z-[2] h-[50px] bg-custom-background-90">
|
||||
<SubGroupSwimlaneHeader
|
||||
issueIds={issueIds}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
group_by={group_by}
|
||||
sub_group_by={sub_group_by}
|
||||
kanbanFilters={kanbanFilters}
|
||||
@@ -328,7 +339,9 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
<SubGroupSwimlane
|
||||
issuesMap={issuesMap}
|
||||
list={subGroupByList}
|
||||
issueIds={issueIds}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
getPaginationData={getPaginationData}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
displayProperties={displayProperties}
|
||||
group_by={group_by}
|
||||
sub_group_by={sub_group_by}
|
||||
@@ -336,6 +349,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
quickActions={quickActions}
|
||||
kanbanFilters={kanbanFilters}
|
||||
handleKanbanFilters={handleKanbanFilters}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
isDragStarted={isDragStarted}
|
||||
disableIssueCreation={disableIssueCreation}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DraggableLocation } from "@hello-pangea/dnd";
|
||||
import { TGroupedIssues, IIssueMap, TSubGroupedIssues, TUnGroupedIssues, TIssue } from "@plane/types";
|
||||
import { TGroupedIssues, IIssueMap, TSubGroupedIssues, TIssue } from "@plane/types";
|
||||
|
||||
const handleSortOrder = (destinationIssues: string[], destinationIndex: number, issueMap: IIssueMap) => {
|
||||
const sortOrderDefaultValue = 65535;
|
||||
@@ -44,7 +44,7 @@ export const handleDragDrop = async (
|
||||
subGroupBy: string | null,
|
||||
groupBy: string | null,
|
||||
issueMap: IIssueMap,
|
||||
issueWithIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined,
|
||||
issueWithIds: TGroupedIssues | TSubGroupedIssues | undefined,
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined,
|
||||
removeIssue: (projectId: string, issueId: string) => Promise<void> | undefined
|
||||
) => {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { FC, useCallback } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TIssue } from "@plane/types";
|
||||
import useSWR from "swr";
|
||||
// types
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { TGroupedIssues, TIssue } from "@plane/types";
|
||||
// constants
|
||||
import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { useIssues, useUser } from "@/hooks/store";
|
||||
|
||||
// hooks
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
// components
|
||||
import { IssueLayoutHOC } from "../issue-layout-HOC";
|
||||
import { List } from "./default";
|
||||
import { IQuickActionProps } from "./list-view-types";
|
||||
// constants
|
||||
// hooks
|
||||
|
||||
type ListStoreType =
|
||||
| EIssuesStoreType.PROJECT
|
||||
@@ -40,7 +41,8 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
|
||||
} = props;
|
||||
|
||||
const { issuesFilter, issues } = useIssues(storeType);
|
||||
const { updateIssue, removeIssue, removeIssueFromView, archiveIssue, restoreIssue } = useIssuesActions(storeType);
|
||||
const { fetchIssues, fetchNextIssues, updateIssue, removeIssue, removeIssueFromView, archiveIssue, restoreIssue } =
|
||||
useIssuesActions(storeType);
|
||||
// mobx store
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
@@ -48,9 +50,24 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
|
||||
|
||||
const { issueMap } = useIssues();
|
||||
|
||||
const displayFilters = issuesFilter?.issueFilters?.displayFilters;
|
||||
const displayProperties = issuesFilter?.issueFilters?.displayProperties;
|
||||
|
||||
const group_by = displayFilters?.group_by || null;
|
||||
const showEmptyGroup = displayFilters?.show_empty_groups ?? false;
|
||||
|
||||
useSWR(
|
||||
`ISSUE_LIST_LAYOUT_${storeType}_${group_by}`,
|
||||
() => fetchIssues("init-loader", { canGroup: true, perPageCount: 50 }),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
const issueIds = issues?.groupedIssueIds || [];
|
||||
const groupedIssueIds = issues?.groupedIssueIds as TGroupedIssues | undefined;
|
||||
|
||||
const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {};
|
||||
const canEditProperties = useCallback(
|
||||
@@ -63,12 +80,6 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
|
||||
[canEditPropertiesBasedOnProject, enableInlineEditing, isEditingAllowed]
|
||||
);
|
||||
|
||||
const displayFilters = issuesFilter?.issueFilters?.displayFilters;
|
||||
const displayProperties = issuesFilter?.issueFilters?.displayProperties;
|
||||
|
||||
const group_by = displayFilters?.group_by || null;
|
||||
const showEmptyGroup = displayFilters?.show_empty_groups ?? false;
|
||||
|
||||
const renderQuickActions = useCallback(
|
||||
(issue: TIssue) => (
|
||||
<QuickActions
|
||||
@@ -85,25 +96,47 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
|
||||
[isEditingAllowed, isCompletedCycle, removeIssue, updateIssue, removeIssueFromView, archiveIssue, restoreIssue]
|
||||
);
|
||||
|
||||
const loadMoreIssues = useCallback(
|
||||
(groupId?: string) => {
|
||||
fetchNextIssues(groupId);
|
||||
},
|
||||
[fetchNextIssues]
|
||||
);
|
||||
|
||||
const getPaginationData = useCallback(
|
||||
(groupId?: string) => issues?.getPaginationData(groupId, undefined),
|
||||
[issues?.getPaginationData]
|
||||
);
|
||||
|
||||
const getGroupIssueCount = useCallback(
|
||||
(groupId?: string) => issues?.getGroupIssueCount(groupId, undefined, false),
|
||||
[issues?.getGroupIssueCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`relative h-full w-full bg-custom-background-90`}>
|
||||
<List
|
||||
issuesMap={issueMap}
|
||||
displayProperties={displayProperties}
|
||||
group_by={group_by}
|
||||
updateIssue={updateIssue}
|
||||
quickActions={renderQuickActions}
|
||||
issueIds={issueIds}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
viewId={viewId}
|
||||
quickAddCallback={issues?.quickAddIssue}
|
||||
enableIssueQuickAdd={!!enableQuickAdd}
|
||||
canEditProperties={canEditProperties}
|
||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||
storeType={storeType}
|
||||
addIssuesToView={addIssuesToView}
|
||||
isCompletedCycle={isCompletedCycle}
|
||||
/>
|
||||
</div>
|
||||
<IssueLayoutHOC storeType={storeType} layout={EIssueLayoutTypes.LIST}>
|
||||
<div className={`relative h-full w-full bg-custom-background-90`}>
|
||||
<List
|
||||
issuesMap={issueMap}
|
||||
displayProperties={displayProperties}
|
||||
group_by={group_by}
|
||||
updateIssue={updateIssue}
|
||||
quickActions={renderQuickActions}
|
||||
groupedIssueIds={groupedIssueIds ?? {}}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
viewId={viewId}
|
||||
getPaginationData={getPaginationData}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
quickAddCallback={issues?.quickAddIssue}
|
||||
enableIssueQuickAdd={!!enableQuickAdd}
|
||||
canEditProperties={canEditProperties}
|
||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||
storeType={storeType}
|
||||
addIssuesToView={addIssuesToView}
|
||||
isCompletedCycle={isCompletedCycle}
|
||||
/>
|
||||
</div>
|
||||
</IssueLayoutHOC>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,9 @@ import { IssueProperties } from "../properties/all-properties";
|
||||
interface IssueBlockProps {
|
||||
issueId: string;
|
||||
issuesMap: TIssueMap;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
updateIssue:
|
||||
| ((projectId: string | null | undefined, issueId: string, data: Partial<TIssue>) => Promise<void>)
|
||||
| undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
@@ -40,7 +42,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
const { isMobile } = usePlatformOS();
|
||||
if (!issue) return null;
|
||||
|
||||
const canEditIssueProperties = canEditProperties(issue.project_id);
|
||||
const canEditIssueProperties = canEditProperties(issue.project_id ?? undefined);
|
||||
const projectIdentifier = getProjectIdentifierById(issue.project_id);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { FC, MutableRefObject } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types";
|
||||
import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types";
|
||||
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
|
||||
import { IssueBlock } from "@/components/issues";
|
||||
// types
|
||||
|
||||
interface Props {
|
||||
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
||||
issueIds: TGroupedIssues | any;
|
||||
issuesMap: TIssueMap;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
updateIssue:
|
||||
| ((projectId: string | null | undefined, issueId: string, data: Partial<TIssue>) => Promise<void>)
|
||||
| undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const IssueBlocksList: FC<Props> = (props) => {
|
||||
export const IssueBlocksList: FC<Props> = observer((props) => {
|
||||
const { issueIds, issuesMap, updateIssue, quickActions, displayProperties, canEditProperties, containerRef } = props;
|
||||
|
||||
return (
|
||||
@@ -28,7 +31,7 @@ export const IssueBlocksList: FC<Props> = (props) => {
|
||||
key={`${issueId}`}
|
||||
defaultHeight="3rem"
|
||||
root={containerRef}
|
||||
classNames={"relative border border-transparent border-b-custom-border-200 last:border-b-transparent"}
|
||||
classNames={"relative border border-transparent border-b-custom-border-200"}
|
||||
changingReference={issueIds}
|
||||
>
|
||||
<IssueBlock
|
||||
@@ -47,4 +50,4 @@ export const IssueBlocksList: FC<Props> = (props) => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,31 +1,42 @@
|
||||
import { useRef } from "react";
|
||||
// components
|
||||
import isNil from "lodash/isNil";
|
||||
import { observer } from "mobx-react";
|
||||
//types
|
||||
import {
|
||||
GroupByColumnTypes,
|
||||
TGroupedIssues,
|
||||
TIssue,
|
||||
IIssueDisplayProperties,
|
||||
TIssueMap,
|
||||
TUnGroupedIssues,
|
||||
IGroupByColumn,
|
||||
TPaginationData,
|
||||
} from "@plane/types";
|
||||
// components
|
||||
import { IssueBlocksList, ListQuickAddIssueForm } from "@/components/issues";
|
||||
// hooks
|
||||
import { ListLoaderItemRow } from "@/components/ui";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
// hooks
|
||||
import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
// utils
|
||||
import { getGroupByColumns, isWorkspaceLevel } from "../utils";
|
||||
//components
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
|
||||
export interface IGroupByList {
|
||||
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
issuesMap: TIssueMap;
|
||||
group_by: string | null;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
updateIssue:
|
||||
| ((projectId: string | null | undefined, issueId: string, data: Partial<TIssue>) => Promise<void>)
|
||||
| undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
enableIssueQuickAdd: boolean;
|
||||
showEmptyGroup?: boolean;
|
||||
getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
|
||||
getGroupIssueCount: (groupId: string | undefined) => number | undefined;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
quickAddCallback?: (
|
||||
workspaceSlug: string,
|
||||
@@ -38,11 +49,12 @@ export interface IGroupByList {
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
||||
viewId?: string;
|
||||
isCompletedCycle?: boolean;
|
||||
loadMoreIssues: (groupId?: string) => void;
|
||||
}
|
||||
|
||||
const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
const GroupByList: React.FC<IGroupByList> = observer((props) => {
|
||||
const {
|
||||
issueIds,
|
||||
groupedIssueIds,
|
||||
issuesMap,
|
||||
group_by,
|
||||
updateIssue,
|
||||
@@ -56,7 +68,10 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
disableIssueCreation,
|
||||
storeType,
|
||||
addIssuesToView,
|
||||
getPaginationData,
|
||||
getGroupIssueCount,
|
||||
isCompletedCycle = false,
|
||||
loadMoreIssues,
|
||||
} = props;
|
||||
// store hooks
|
||||
const member = useMember();
|
||||
@@ -66,8 +81,11 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
const cycle = useCycle();
|
||||
const projectModule = useModule();
|
||||
|
||||
const intersectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useIntersectionObserver(containerRef, intersectionRef, loadMoreIssues, `50% 0% 50% 0%`);
|
||||
|
||||
const groups = getGroupByColumns(
|
||||
group_by as GroupByColumnTypes,
|
||||
project,
|
||||
@@ -111,14 +129,11 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
return preloadedData;
|
||||
};
|
||||
|
||||
const validateEmptyIssueGroups = (issues: TIssue[]) => {
|
||||
const issuesCount = issues?.length || 0;
|
||||
if (!showEmptyGroup && issuesCount <= 0) return false;
|
||||
const validateEmptyIssueGroups = (issueCount: number = 0) => {
|
||||
if (!showEmptyGroup && issueCount <= 0) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const is_list = group_by === null ? true : false;
|
||||
|
||||
const isGroupByCreatedBy = group_by === "created_by";
|
||||
|
||||
return (
|
||||
@@ -128,15 +143,26 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
>
|
||||
{groups &&
|
||||
groups.length > 0 &&
|
||||
groups.map(
|
||||
(_list: IGroupByColumn) =>
|
||||
validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
|
||||
groups.map((_list: IGroupByColumn) => {
|
||||
const groupIssueIds = groupedIssueIds?.[_list.id];
|
||||
const groupIssueCount = getGroupIssueCount(_list.id);
|
||||
|
||||
const nextPageResults = getPaginationData(_list.id)?.nextPageResults;
|
||||
|
||||
const shouldLoadMore =
|
||||
nextPageResults === undefined && groupIssueCount !== undefined
|
||||
? groupIssueIds?.length < groupIssueCount
|
||||
: !!nextPageResults;
|
||||
return (
|
||||
groupIssueIds &&
|
||||
!isNil(groupIssueCount) &&
|
||||
validateEmptyIssueGroups(groupIssueCount) && (
|
||||
<div key={_list.id} className={`flex flex-shrink-0 flex-col`}>
|
||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 px-3 py-1">
|
||||
<HeaderGroupByCard
|
||||
icon={_list.icon}
|
||||
title={_list.name || ""}
|
||||
count={is_list ? issueIds?.length || 0 : issueIds?.[_list.id]?.length || 0}
|
||||
count={groupIssueCount}
|
||||
issuePayload={_list.payload}
|
||||
disableIssueCreation={disableIssueCreation || isGroupByCreatedBy || isCompletedCycle}
|
||||
storeType={storeType}
|
||||
@@ -144,9 +170,9 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{issueIds && (
|
||||
{groupedIssueIds && (
|
||||
<IssueBlocksList
|
||||
issueIds={is_list ? issueIds || 0 : issueIds?.[_list.id] || 0}
|
||||
issueIds={groupIssueIds}
|
||||
issuesMap={issuesMap}
|
||||
updateIssue={updateIssue}
|
||||
quickActions={quickActions}
|
||||
@@ -155,6 +181,19 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)}
|
||||
{shouldLoadMore &&
|
||||
(group_by ? (
|
||||
<div
|
||||
className={
|
||||
"h-11 relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm text-custom-primary-100 hover:underline cursor-pointer"
|
||||
}
|
||||
onClick={() => loadMoreIssues(_list.id)}
|
||||
>
|
||||
Load more ↓
|
||||
</div>
|
||||
) : (
|
||||
<ListLoaderItemRow ref={intersectionRef} />
|
||||
))}
|
||||
|
||||
{enableIssueQuickAdd && !disableIssueCreation && !isGroupByCreatedBy && !isCompletedCycle && (
|
||||
<div className="sticky bottom-0 z-[1] w-full flex-shrink-0">
|
||||
@@ -167,16 +206,19 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export interface IList {
|
||||
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
issuesMap: TIssueMap;
|
||||
group_by: string | null;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
updateIssue:
|
||||
| ((projectId: string | null | undefined, issueId: string, data: Partial<TIssue>) => Promise<void>)
|
||||
| undefined;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
showEmptyGroup: boolean;
|
||||
@@ -192,12 +234,15 @@ export interface IList {
|
||||
disableIssueCreation?: boolean;
|
||||
storeType: EIssuesStoreType;
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
||||
getPaginationData: (groupId: string | undefined) => TPaginationData | undefined;
|
||||
getGroupIssueCount: (groupId: string | undefined) => number | undefined;
|
||||
loadMoreIssues: (groupId?: string) => void;
|
||||
isCompletedCycle?: boolean;
|
||||
}
|
||||
|
||||
export const List: React.FC<IList> = (props) => {
|
||||
const {
|
||||
issueIds,
|
||||
groupedIssueIds,
|
||||
issuesMap,
|
||||
group_by,
|
||||
updateIssue,
|
||||
@@ -208,18 +253,22 @@ export const List: React.FC<IList> = (props) => {
|
||||
showEmptyGroup,
|
||||
enableIssueQuickAdd,
|
||||
canEditProperties,
|
||||
getPaginationData,
|
||||
getGroupIssueCount,
|
||||
disableIssueCreation,
|
||||
storeType,
|
||||
addIssuesToView,
|
||||
loadMoreIssues,
|
||||
isCompletedCycle = false,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<GroupByList
|
||||
issueIds={issueIds as TUnGroupedIssues}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
issuesMap={issuesMap}
|
||||
group_by={group_by}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
updateIssue={updateIssue}
|
||||
quickActions={quickActions}
|
||||
displayProperties={displayProperties}
|
||||
@@ -227,6 +276,8 @@ export const List: React.FC<IList> = (props) => {
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
canEditProperties={canEditProperties}
|
||||
quickAddCallback={quickAddCallback}
|
||||
getPaginationData={getPaginationData}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
viewId={viewId}
|
||||
disableIssueCreation={disableIssueCreation}
|
||||
storeType={storeType}
|
||||
|
||||
@@ -156,7 +156,7 @@ export const ListQuickAddIssueForm: FC<IListQuickAddIssueForm> = observer((props
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
||||
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
||||
<span className="text-sm font-medium loader">New Issue</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,9 @@ import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-
|
||||
|
||||
export interface IIssueProperties {
|
||||
issue: TIssue;
|
||||
updateIssue: ((projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
|
||||
updateIssue:
|
||||
| ((projectId: string | undefined | null, issueId: string, data: Partial<TIssue>) => Promise<void>)
|
||||
| undefined;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
isReadOnly: boolean;
|
||||
className: string;
|
||||
@@ -242,7 +244,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
});
|
||||
};
|
||||
|
||||
if (!displayProperties) return null;
|
||||
if (!displayProperties || !issue.project_id) return null;
|
||||
|
||||
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
|
||||
|
||||
@@ -274,7 +276,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
|
||||
<div className="h-5">
|
||||
<PriorityDropdown
|
||||
value={issue?.priority || null}
|
||||
value={issue?.priority}
|
||||
onChange={handlePriority}
|
||||
disabled={isReadOnly}
|
||||
buttonVariant="border-without-text"
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import React, { Fragment, useCallback } from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
import { TIssue, IIssueDisplayFilterOptions } from "@plane/types";
|
||||
// hooks
|
||||
// components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { GlobalViewsAppliedFiltersRoot, IssuePeekOverview } from "@/components/issues";
|
||||
import { SpreadsheetView } from "@/components/issues/issue-layouts";
|
||||
import { AllIssueQuickActions } from "@/components/issues/issue-layouts/quick-action-dropdowns";
|
||||
import { SpreadsheetLayoutLoader } from "@/components/ui";
|
||||
// types
|
||||
// constants
|
||||
import { EMPTY_STATE_DETAILS, EmptyStateType } from "@/constants/empty-state";
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
import {
|
||||
EIssueFilterType,
|
||||
EIssueLayoutTypes,
|
||||
EIssuesStoreType,
|
||||
ISSUE_DISPLAY_FILTERS_BY_LAYOUT,
|
||||
} from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { useApplication, useEventTracker, useGlobalView, useIssues, useProject, useUser } from "@/hooks/store";
|
||||
// hooks
|
||||
import { useGlobalView, useIssues, useUser } from "@/hooks/store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
|
||||
// store
|
||||
import { ALL_ISSUES } from "@/store/issue/helpers/base-issues.store";
|
||||
import { IssuePeekOverview } from "../../peek-overview";
|
||||
import { IssueLayoutHOC } from "../issue-layout-HOC";
|
||||
|
||||
export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
@@ -27,24 +32,16 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
//swr hook for fetching issue properties
|
||||
useWorkspaceIssueProperties(workspaceSlug);
|
||||
// store
|
||||
const { commandPalette: commandPaletteStore } = useApplication();
|
||||
const {
|
||||
issuesFilter: { filters, fetchFilters, updateFilters },
|
||||
issues: { loader, groupedIssueIds, fetchIssues },
|
||||
issues: { loader, getPaginationData, groupedIssueIds, fetchIssues, fetchNextIssues },
|
||||
} = useIssues(EIssuesStoreType.GLOBAL);
|
||||
const { updateIssue, removeIssue, archiveIssue } = useIssuesActions(EIssuesStoreType.GLOBAL);
|
||||
|
||||
const { dataViewId, issueIds } = groupedIssueIds;
|
||||
const {
|
||||
membership: { currentWorkspaceAllProjectsRole },
|
||||
} = useUser();
|
||||
const { fetchAllGlobalViews } = useGlobalView();
|
||||
const { workspaceProjectIds } = useProject();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
|
||||
const isDefaultView = ["all-issues", "assigned", "created", "subscribed"].includes(groupedIssueIds.dataViewId);
|
||||
const currentView = isDefaultView ? groupedIssueIds.dataViewId : "custom-view";
|
||||
|
||||
// filter init from the query params
|
||||
|
||||
const routerFilterParams = () => {
|
||||
@@ -76,6 +73,10 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNextPages = useCallback(() => {
|
||||
if (workspaceSlug && globalViewId) fetchNextIssues(workspaceSlug.toString(), globalViewId.toString());
|
||||
}, [fetchNextIssues, workspaceSlug, globalViewId]);
|
||||
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_GLOBAL_VIEWS_${workspaceSlug}` : null,
|
||||
async () => {
|
||||
@@ -92,7 +93,15 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
if (workspaceSlug && globalViewId) {
|
||||
await fetchAllGlobalViews(workspaceSlug.toString());
|
||||
await fetchFilters(workspaceSlug.toString(), globalViewId.toString());
|
||||
await fetchIssues(workspaceSlug.toString(), globalViewId.toString(), issueIds ? "mutation" : "init-loader");
|
||||
await fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
globalViewId.toString(),
|
||||
groupedIssueIds ? "mutation" : "init-loader",
|
||||
{
|
||||
canGroup: false,
|
||||
perPageCount: 100,
|
||||
}
|
||||
);
|
||||
routerFilterParams();
|
||||
}
|
||||
},
|
||||
@@ -136,58 +145,35 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
|
||||
handleUpdate={async (data) => updateIssue && updateIssue(issue.project_id, issue.id, data)}
|
||||
handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)}
|
||||
portalElement={portalElement}
|
||||
readOnly={!canEditProperties(issue.project_id)}
|
||||
readOnly={!canEditProperties(issue.project_id ?? undefined)}
|
||||
/>
|
||||
),
|
||||
[canEditProperties, removeIssue, updateIssue, archiveIssue]
|
||||
);
|
||||
|
||||
if (loader === "init-loader" || !globalViewId || globalViewId !== dataViewId || !issueIds) {
|
||||
if (loader === "init-loader" || !globalViewId || !groupedIssueIds) {
|
||||
return <SpreadsheetLayoutLoader />;
|
||||
}
|
||||
|
||||
const emptyStateType =
|
||||
(workspaceProjectIds ?? []).length > 0 ? `workspace-${currentView}` : EmptyStateType.WORKSPACE_NO_PROJECTS;
|
||||
const issueIds = groupedIssueIds[ALL_ISSUES];
|
||||
const nextPageResults = getPaginationData(ALL_ISSUES, undefined)?.nextPageResults;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="relative h-full w-full flex flex-col">
|
||||
<GlobalViewsAppliedFiltersRoot globalViewId={globalViewId} />
|
||||
{issueIds.length === 0 ? (
|
||||
<EmptyState
|
||||
type={emptyStateType as keyof typeof EMPTY_STATE_DETAILS}
|
||||
size="sm"
|
||||
primaryButtonOnClick={
|
||||
(workspaceProjectIds ?? []).length > 0
|
||||
? currentView !== "custom-view" && currentView !== "subscribed"
|
||||
? () => {
|
||||
setTrackElement("All issues empty state");
|
||||
commandPaletteStore.toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
|
||||
}
|
||||
: undefined
|
||||
: () => {
|
||||
setTrackElement("All issues empty state");
|
||||
commandPaletteStore.toggleCreateProjectModal(true);
|
||||
}
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Fragment>
|
||||
<SpreadsheetView
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFilterUpdate={handleDisplayFiltersUpdate}
|
||||
issueIds={issueIds}
|
||||
quickActions={renderQuickActions}
|
||||
updateIssue={updateIssue}
|
||||
canEditProperties={canEditProperties}
|
||||
viewId={globalViewId}
|
||||
/>
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<IssueLayoutHOC storeType={EIssuesStoreType.GLOBAL} layout={EIssueLayoutTypes.SPREADSHEET}>
|
||||
<SpreadsheetView
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFilterUpdate={handleDisplayFiltersUpdate}
|
||||
issueIds={Array.isArray(issueIds) ? issueIds : []}
|
||||
quickActions={renderQuickActions}
|
||||
updateIssue={updateIssue}
|
||||
canEditProperties={canEditProperties}
|
||||
viewId={globalViewId.toString()}
|
||||
canLoadMoreIssues={!!nextPageResults}
|
||||
loadMoreIssues={fetchNextPages}
|
||||
/>
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</IssueLayoutHOC>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,10 +7,8 @@ import useSWR from "swr";
|
||||
import {
|
||||
ArchivedIssueListLayout,
|
||||
ArchivedIssueAppliedFiltersRoot,
|
||||
ProjectArchivedEmptyState,
|
||||
IssuePeekOverview,
|
||||
} from "@/components/issues";
|
||||
import { ListLayoutLoader } from "@/components/ui";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
// ui
|
||||
import { useIssues } from "@/hooks/store";
|
||||
@@ -20,43 +18,28 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// hooks
|
||||
const { issues, issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
const { issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `ARCHIVED_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString());
|
||||
await issues?.fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issues?.groupedIssueIds ? "mutation" : "init-loader"
|
||||
);
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) {
|
||||
return <ListLayoutLoader />;
|
||||
}
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
return (
|
||||
<>
|
||||
<ArchivedIssueAppliedFiltersRoot />
|
||||
{issues?.groupedIssueIds?.length === 0 ? (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<ProjectArchivedEmptyState />
|
||||
</div>
|
||||
) : (
|
||||
<Fragment>
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
<ArchivedIssueListLayout />
|
||||
</div>
|
||||
<IssuePeekOverview is_archived />
|
||||
</Fragment>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
import React, { Fragment, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import size from "lodash/size";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
import { IIssueFilterOptions } from "@plane/types";
|
||||
// hooks
|
||||
// components
|
||||
import { TransferIssues, TransferIssuesModal } from "@/components/cycles";
|
||||
import {
|
||||
CycleAppliedFiltersRoot,
|
||||
CycleCalendarLayout,
|
||||
CycleEmptyState,
|
||||
CycleGanttLayout,
|
||||
CycleKanBanLayout,
|
||||
CycleListLayout,
|
||||
CycleSpreadsheetLayout,
|
||||
IssuePeekOverview,
|
||||
} from "@/components/issues";
|
||||
import { ActiveLoader } from "@/components/ui";
|
||||
// constants
|
||||
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
|
||||
import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue";
|
||||
// hooks
|
||||
import { useCycle, useIssues } from "@/hooks/store";
|
||||
// types
|
||||
|
||||
const CycleIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undefined }) => {
|
||||
switch (props.activeLayout) {
|
||||
case EIssueLayoutTypes.LIST:
|
||||
return <CycleListLayout />;
|
||||
case EIssueLayoutTypes.KANBAN:
|
||||
return <CycleKanBanLayout />;
|
||||
case EIssueLayoutTypes.CALENDAR:
|
||||
return <CycleCalendarLayout />;
|
||||
case EIssueLayoutTypes.GANTT:
|
||||
return <CycleGanttLayout />;
|
||||
case EIssueLayoutTypes.SPREADSHEET:
|
||||
return <CycleSpreadsheetLayout />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||
// store hooks
|
||||
const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { getCycleById } = useCycle();
|
||||
// state
|
||||
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
|
||||
@@ -40,12 +53,6 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
async () => {
|
||||
if (workspaceSlug && projectId && cycleId) {
|
||||
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), cycleId.toString());
|
||||
await issues?.fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issues?.groupedIssueIds ? "mutation" : "init-loader",
|
||||
cycleId.toString()
|
||||
);
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
@@ -56,37 +63,8 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase() ?? "draft";
|
||||
|
||||
const userFilters = issuesFilter?.issueFilters?.filters;
|
||||
|
||||
const issueFilterCount = size(
|
||||
Object.fromEntries(
|
||||
Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0)
|
||||
)
|
||||
);
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = [];
|
||||
});
|
||||
issuesFilter.updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{
|
||||
...newFilters,
|
||||
},
|
||||
cycleId.toString()
|
||||
);
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !projectId || !cycleId) return <></>;
|
||||
|
||||
if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) {
|
||||
return <>{activeLayout && <ActiveLoader layout={activeLayout} />}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TransferIssuesModal handleClose={() => setTransferIssuesModal(false)} isOpen={transferIssuesModal} />
|
||||
@@ -99,36 +77,11 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
)}
|
||||
<CycleAppliedFiltersRoot />
|
||||
|
||||
{issues?.groupedIssueIds?.length === 0 ? (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<CycleEmptyState
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
cycleId={cycleId.toString()}
|
||||
activeLayout={activeLayout}
|
||||
handleClearAllFilters={handleClearAllFilters}
|
||||
isEmptyFilters={issueFilterCount > 0}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Fragment>
|
||||
<div className="h-full w-full overflow-auto">
|
||||
{activeLayout === "list" ? (
|
||||
<CycleListLayout />
|
||||
) : activeLayout === "kanban" ? (
|
||||
<CycleKanBanLayout />
|
||||
) : activeLayout === "calendar" ? (
|
||||
<CycleCalendarLayout />
|
||||
) : activeLayout === "gantt_chart" ? (
|
||||
<CycleGanttLayout />
|
||||
) : activeLayout === "spreadsheet" ? (
|
||||
<CycleSpreadsheetLayout />
|
||||
) : null}
|
||||
</div>
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</Fragment>
|
||||
)}
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<CycleIssueLayout activeLayout={activeLayout} />
|
||||
</div>
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,34 +4,37 @@ import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { IssuePeekOverview } from "@/components/issues/peek-overview";
|
||||
import { ActiveLoader } from "@/components/ui";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue";
|
||||
import { useIssues } from "@/hooks/store";
|
||||
// components
|
||||
import { ProjectDraftEmptyState } from "../empty-states";
|
||||
import { DraftIssueAppliedFiltersRoot } from "../filters/applied-filters/roots/draft-issue";
|
||||
import { DraftKanBanLayout } from "../kanban/roots/draft-issue-root";
|
||||
import { DraftIssueListLayout } from "../list/roots/draft-issue-root";
|
||||
// ui
|
||||
// constants
|
||||
|
||||
const DraftIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undefined }) => {
|
||||
switch (props.activeLayout) {
|
||||
case EIssueLayoutTypes.LIST:
|
||||
return <DraftIssueListLayout />;
|
||||
case EIssueLayoutTypes.KANBAN:
|
||||
return <DraftKanBanLayout />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
export const DraftIssueLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// hooks
|
||||
const { issues, issuesFilter } = useIssues(EIssuesStoreType.DRAFT);
|
||||
const { issuesFilter } = useIssues(EIssuesStoreType.DRAFT);
|
||||
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `DRAFT_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString());
|
||||
await issues?.fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issues?.groupedIssueIds ? "mutation" : "init-loader"
|
||||
);
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
@@ -41,29 +44,14 @@ export const DraftIssueLayoutRoot: React.FC = observer(() => {
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) {
|
||||
return <>{activeLayout && <ActiveLoader layout={activeLayout} />}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<DraftIssueAppliedFiltersRoot />
|
||||
|
||||
{issues?.groupedIssueIds?.length === 0 ? (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<ProjectDraftEmptyState />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
{activeLayout === "list" ? (
|
||||
<DraftIssueListLayout />
|
||||
) : activeLayout === "kanban" ? (
|
||||
<DraftKanBanLayout />
|
||||
) : null}
|
||||
{/* issue peek overview */}
|
||||
<IssuePeekOverview is_draft />
|
||||
</div>
|
||||
)}
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
<DraftIssueLayout activeLayout={activeLayout} />
|
||||
{/* issue peek overview */}
|
||||
<IssuePeekOverview is_draft />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user