Compare commits

...

77 Commits

Author SHA1 Message Date
pablohashescobar
1d697c9d78 Merge branch 'feat/pagination' of github.com:makeplane/plane into feat/pagination 2024-03-27 18:06:18 +05:30
pablohashescobar
7c9a2b99e6 dev: fix lint errors 2024-03-27 18:05:39 +05:30
pablohashescobar
4960e70df6 Merge branch 'develop' of github.com:makeplane/plane into feat/pagination 2024-03-27 18:02:38 +05:30
rahulramesha
588a096c94 active cycle issues pagination 2024-03-27 17:37:24 +05:30
rahulramesha
9b4176aa17 fix calendar pagination 2024-03-27 16:38:12 +05:30
rahulramesha
57133122ec grouped pagination cursor logic changes 2024-03-27 16:13:13 +05:30
rahulramesha
faec7d98a9 fix priority order by 2024-03-27 16:00:31 +05:30
rahulramesha
e0ec4142ae Merge branch 'feat/pagination' of github.com:makeplane/plane into feat/pagination 2024-03-27 15:50:04 +05:30
rahulramesha
d547ae7896 fix major build errors 2024-03-27 15:49:25 +05:30
rahulramesha
d354ff9a1a fix minor build errors 2024-03-27 15:02:31 +05:30
pablohashescobar
c3bb16f174 fix: imports 2024-03-27 14:58:59 +05:30
rahulramesha
3e55490bbd Merge branch 'develop' of github.com:makeplane/plane into feat/pagination 2024-03-27 14:15:49 +05:30
pablohashescobar
eb9dca6d4b dev: fix sub grouping 2024-03-26 18:04:00 +05:30
pablohashescobar
19fa1c28b9 Merge branch 'feat/pagination' of github.com:makeplane/plane into feat/pagination 2024-03-26 17:08:45 +05:30
pablohashescobar
3a07eae192 dev: fix cycle and module issue 2024-03-26 17:08:02 +05:30
rahulramesha
0af1f9e1f0 fix subGroupKey generation when subGroupId is null 2024-03-26 16:52:55 +05:30
rahulramesha
236f0c544a fix non render of Issues 2024-03-26 16:52:00 +05:30
rahulramesha
834bf27231 fix order by for modules and cycles 2024-03-26 14:09:43 +05:30
pablohashescobar
4341bce9a8 dev: add sorting 2024-03-26 13:54:38 +05:30
rahulramesha
61c6cb77e9 Merge branch 'feat/pagination' of github.com:makeplane/plane into feat/pagination 2024-03-26 13:37:36 +05:30
pablohashescobar
f760836a90 dev: fix issue creation 2024-03-26 13:36:53 +05:30
rahulramesha
295fdc9386 fix issue quick add 2024-03-26 13:08:48 +05:30
rahulramesha
eea1cce926 Fix logic for load more in Kanban 2024-03-26 12:28:28 +05:30
rahulramesha
26e8cd1399 fix subGroupCount 2024-03-26 12:28:11 +05:30
rahulramesha
a9ffd18a2f fix orderby priority for spreadsheet 2024-03-26 12:27:43 +05:30
pablohashescobar
6cdc670bc3 dev: add comments and fix ordering 2024-03-25 15:36:39 +05:30
pablohashescobar
24bdcb682b fix: total results for sub group pagination 2024-03-25 13:07:42 +05:30
pablohashescobar
6491b40e04 Merge branch 'feat/pagination' of github.com:makeplane/plane into feat/pagination 2024-03-22 20:59:02 +05:30
pablohashescobar
3f19326c2c dev: fix order by for pagination 2024-03-22 20:58:08 +05:30
rahulramesha
5d2e89c285 fix reeordering while update 2024-03-22 19:57:36 +05:30
pablohashescobar
b4416b9172 Merge branch 'feat/pagination' of github.com:makeplane/plane into feat/pagination 2024-03-22 19:40:39 +05:30
pablohashescobar
4341f00dd0 dev: grouping for priority 2024-03-22 19:40:16 +05:30
rahulramesha
e3b5b5d65b Merge branch 'feat/pagination' of github.com:makeplane/plane into feat/pagination 2024-03-22 18:17:47 +05:30
rahulramesha
fb4ac91f22 fix group by bugs 2024-03-22 18:17:27 +05:30
pablohashescobar
aea30897d5 dev: fix priority ordering 2024-03-22 17:42:57 +05:30
pablohashescobar
33a64fc67b Merge branch 'feat/pagination' of github.com:makeplane/plane into feat/pagination 2024-03-22 17:28:14 +05:30
pablohashescobar
b18987bcef fix: label id and assignee id interchange 2024-03-22 17:27:49 +05:30
rahulramesha
0d0bfd8a32 implement new logic for pagination layouts 2024-03-22 15:55:08 +05:30
rahulramesha
a28afad8c8 Merge branch 'feat/pagination' of github.com:makeplane/plane into feat/pagination 2024-03-22 14:14:59 +05:30
pablohashescobar
b7dcbe91dd dev: group by counts 2024-03-22 14:14:07 +05:30
rahulramesha
3bacdd6dd4 Merge branch 'feat/pagination' of github.com:makeplane/plane into feat/pagination 2024-03-22 13:58:54 +05:30
pablohashescobar
b8aea19715 dev: date filtering for issues 2024-03-21 13:39:37 +05:30
rahulramesha
3f41c260fc Merge branch 'feat/pagination' of github.com:makeplane/plane into feat/pagination 2024-03-20 17:42:43 +05:30
pablohashescobar
6ebd215fd6 dev: fix pagination count 2024-03-20 17:41:20 +05:30
rahulramesha
322d434f7e Merge branch 'feat/pagination' of github.com:makeplane/plane into feat/pagination 2024-03-20 16:44:57 +05:30
rahulramesha
ce43067bc1 restructure gantt layout charts 2024-03-18 13:12:10 +05:30
pablohashescobar
981ac13812 dev: sub grouping paginator 2024-03-15 12:31:31 +05:30
pablohashescobar
d90845b260 dev: grouped paginator 2024-03-15 12:02:24 +05:30
pablohashescobar
cd302d061e dev: sub group paginator 2024-03-15 11:55:34 +05:30
pablohashescobar
ae339bc19c fix: order by grouped pagination 2024-03-13 16:11:16 +05:30
rahulramesha
cf470d715a implement pagination for spreadsheet, list, kanban and calendar 2024-03-13 12:26:10 +05:30
rahulramesha
3b3f04b7e7 Merge branch 'feat/pagination' of github.com:makeplane/plane into feat/pagination 2024-03-12 19:39:46 +05:30
pablohashescobar
0af2da2dfe chore: paginator changes 2024-03-12 19:33:46 +05:30
rahulramesha
b12a9e3621 issue pagination store changes 2024-03-12 15:17:47 +05:30
pablohashescobar
26ec7323e5 dev: add total pages key 2024-03-12 14:52:24 +05:30
pablohashescobar
8ea0528c7d Merge branch 'develop' of github.com:makeplane/plane into feat/pagination 2024-03-12 13:21:14 +05:30
rahulramesha
63b850f92f fix some build errors due to type changes 2024-03-08 18:21:28 +05:30
rahulramesha
026bc9318f make store changes for pagination 2024-03-08 17:03:35 +05:30
pablohashescobar
425b36e391 dev: add ruff in dependencies 2024-03-07 15:14:15 +05:30
pablohashescobar
9b84fa060f dev: remove unused imports 2024-03-07 15:13:06 +05:30
pablohashescobar
9d60aaddd7 dev: fix grouping on taget date and project_id 2024-03-07 15:11:44 +05:30
pablohashescobar
75458e33ba fix: imports 2024-03-07 14:06:06 +05:30
pablohashescobar
4899d7df3d Merge branch 'develop' of github.com:makeplane/plane into feat/pagination 2024-03-07 14:05:56 +05:30
pablohashescobar
144d47fdec dev: state__group pagination 2024-02-29 23:03:51 +05:30
pablohashescobar
ffeb77ec86 dev: fix paginating true list 2024-02-29 16:38:53 +05:30
pablohashescobar
a123eea52f dev: fix paginator for single groups 2024-02-29 14:03:40 +05:30
pablohashescobar
880bc00666 dev: ungrouped list 2024-02-29 13:10:18 +05:30
pablohashescobar
db31644313 dev: grouped pagination for empty groups 2024-02-29 12:28:03 +05:30
pablohashescobar
3929f97167 Merge branch 'develop' of github.com:makeplane/plane into feat/pagination 2024-02-28 19:15:35 +05:30
pablohashescobar
f292ee00a8 dev: paginating issue apis 2024-02-28 18:29:17 +05:30
pablohashescobar
7060fb712f Merge branch 'develop' of github.com:makeplane/plane into feat/pagination 2024-02-28 17:13:45 +05:30
pablohashescobar
84160e3d8d dev: refactor pagination 2024-02-28 16:54:46 +05:30
pablohashescobar
e82d7a2aa8 dev: paginate single entities 2024-02-27 19:55:21 +05:30
pablohashescobar
837193cda6 Merge branch 'develop' of github.com:makeplane/plane into feat/pagination 2024-02-27 17:13:48 +05:30
pablohashescobar
32f2719ca0 dev: group pagination 2024-02-27 17:05:22 +05:30
pablohashescobar
2efa21e8f4 dev: pagination for spreadhseet and gantt 2024-02-26 14:26:37 +05:30
pablohashescobar
0076b09131 dev: separate order by of issue queryset to separate utilty function 2024-02-26 12:44:53 +05:30
170 changed files with 6479 additions and 4745 deletions

View File

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

View File

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

View File

@@ -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", [])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

@@ -34,4 +34,4 @@ posthog==3.0.2
cryptography==42.0.4
lxml==4.9.3
boto3==1.28.40
ruff==0.3.1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

@@ -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 &darr;
</div>
</Link>
))
)}
</>
) : (
<div className="flex items-center justify-center h-full w-full">
<EmptyState

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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}</>;
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,6 @@ export const CycleKanBanLayout: React.FC = observer(() => {
return (
<BaseKanBanRoot
showLoader
QuickActions={CycleIssueQuickActions}
viewId={cycleId?.toString() ?? ""}
storeType={EIssuesStoreType.CYCLE}

View File

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

View File

@@ -21,7 +21,6 @@ export const ModuleKanBanLayout: React.FC = observer(() => {
return (
<BaseKanBanRoot
showLoader
QuickActions={ModuleIssueQuickActions}
viewId={moduleId?.toString()}
storeType={EIssuesStoreType.MODULE}

View File

@@ -22,7 +22,6 @@ export const ProfileIssuesKanBanLayout: React.FC = observer(() => {
return (
<BaseKanBanRoot
showLoader
QuickActions={ProjectIssueQuickActions}
storeType={EIssuesStoreType.PROFILE}
canEditPropertiesBasedOnProject={canEditPropertiesBasedOnProject}

View File

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

View File

@@ -16,7 +16,6 @@ export const ProjectViewKanBanLayout: React.FC = observer(() => {
return (
<BaseKanBanRoot
showLoader
QuickActions={ProjectIssueQuickActions}
storeType={EIssuesStoreType.PROJECT_VIEW}
viewId={viewId?.toString()}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &darr;
</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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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