mirror of
https://github.com/gosticks/plane.git
synced 2025-10-16 12:45:33 +00:00
[WEB-4951] [WEB-4884] feat: work item filters revamp (#7810)
This commit is contained in:
parent
e6a7ca4c72
commit
9aef5d4aa9
@ -1080,6 +1080,9 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
cycle_properties.filters = request.data.get("filters", cycle_properties.filters)
|
||||
cycle_properties.rich_filters = request.data.get(
|
||||
"rich_filters", cycle_properties.rich_filters
|
||||
)
|
||||
cycle_properties.display_filters = request.data.get(
|
||||
"display_filters", cycle_properties.display_filters
|
||||
)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
# Python imports
|
||||
import copy
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
@ -28,11 +29,15 @@ from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.filters import ComplexFilterBackend
|
||||
from plane.utils.filters import IssueFilterSet
|
||||
|
||||
|
||||
class CycleIssueViewSet(BaseViewSet):
|
||||
serializer_class = CycleIssueSerializer
|
||||
model = CycleIssue
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
webhook_event = "cycle_issue"
|
||||
bulk = True
|
||||
@ -65,24 +70,9 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def list(self, request, slug, project_id, cycle_id):
|
||||
order_by_param = request.GET.get("order_by", "created_at")
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
|
||||
)
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related(
|
||||
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
|
||||
)
|
||||
.filter(**filters)
|
||||
.annotate(
|
||||
def apply_annotations(self, issues):
|
||||
return (
|
||||
issues.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
@ -110,11 +100,36 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related(
|
||||
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
|
||||
)
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def list(self, request, slug, project_id, cycle_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
|
||||
)
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
)
|
||||
|
||||
# Apply filtering from filterset
|
||||
issue_queryset = self.filter_queryset(issue_queryset)
|
||||
|
||||
# Apply legacy filters
|
||||
issue_queryset = issue_queryset.filter(**filters)
|
||||
|
||||
# Total count queryset
|
||||
total_issue_queryset = copy.deepcopy(issue_queryset)
|
||||
|
||||
# Applying annotations to the issue queryset
|
||||
issue_queryset = self.apply_annotations(issue_queryset)
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
issue_queryset = issue_queryset.filter(**filters)
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
issue_queryset=issue_queryset, order_by_param=order_by_param
|
||||
@ -145,6 +160,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@ -179,6 +195,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@ -205,6 +222,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
# Python imports
|
||||
import copy
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
@ -41,27 +42,20 @@ from plane.utils.host import base_host
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, BaseAPIView
|
||||
from plane.utils.filters import ComplexFilterBackend
|
||||
from plane.utils.filters import IssueFilterSet
|
||||
|
||||
|
||||
class IssueArchiveViewSet(BaseViewSet):
|
||||
serializer_class = IssueFlatSerializer
|
||||
model = Issue
|
||||
|
||||
def get_queryset(self):
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
def apply_annotations(self, issues):
|
||||
return (
|
||||
Issue.objects.annotate(
|
||||
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(deleted_at__isnull=True)
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
issues.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
@ -95,6 +89,15 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.objects.filter(Q(type__isnull=True) | Q(type__is_epic=False))
|
||||
.filter(archived_at__isnull=False)
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@ -105,26 +108,25 @@ class IssueArchiveViewSet(BaseViewSet):
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
||||
total_issue_queryset = Issue.objects.filter(
|
||||
deleted_at__isnull=True,
|
||||
archived_at__isnull=False,
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
).filter(**filters)
|
||||
|
||||
total_issue_queryset = (
|
||||
total_issue_queryset
|
||||
if show_sub_issues == "true"
|
||||
else total_issue_queryset.filter(parent__isnull=True)
|
||||
)
|
||||
issue_queryset = self.get_queryset()
|
||||
|
||||
issue_queryset = (
|
||||
issue_queryset
|
||||
if show_sub_issues == "true"
|
||||
else issue_queryset.filter(parent__isnull=True)
|
||||
)
|
||||
# Apply filtering from filterset
|
||||
issue_queryset = self.filter_queryset(issue_queryset)
|
||||
|
||||
# Apply legacy filters
|
||||
issue_queryset = issue_queryset.filter(**filters)
|
||||
|
||||
# Total count queryset
|
||||
total_issue_queryset = copy.deepcopy(issue_queryset)
|
||||
|
||||
# Applying annotations to the issue queryset
|
||||
issue_queryset = self.apply_annotations(issue_queryset)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
issue_queryset=issue_queryset, order_by_param=order_by_param
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
# Python imports
|
||||
import copy
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
@ -6,16 +7,16 @@ 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 (
|
||||
Count,
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
UUIDField,
|
||||
Value,
|
||||
Subquery,
|
||||
Count,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
@ -27,50 +28,55 @@ from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.app.serializers import (
|
||||
IssueCreateSerializer,
|
||||
IssueDetailSerializer,
|
||||
IssueUserPropertySerializer,
|
||||
IssueSerializer,
|
||||
IssueListDetailSerializer,
|
||||
IssueSerializer,
|
||||
IssueUserPropertySerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.bgtasks.issue_description_version_task import issue_description_version_task
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
IssueUserProperty,
|
||||
IssueReaction,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
ProjectMember,
|
||||
CycleIssue,
|
||||
UserRecentVisit,
|
||||
ModuleIssue,
|
||||
IssueRelation,
|
||||
FileAsset,
|
||||
IntakeIssue,
|
||||
Issue,
|
||||
IssueAssignee,
|
||||
IssueLabel,
|
||||
IntakeIssue,
|
||||
IssueLink,
|
||||
IssueReaction,
|
||||
IssueRelation,
|
||||
IssueSubscriber,
|
||||
IssueUserProperty,
|
||||
ModuleIssue,
|
||||
Project,
|
||||
ProjectMember,
|
||||
UserRecentVisit,
|
||||
)
|
||||
from plane.utils.filters import ComplexFilterBackend, IssueFilterSet
|
||||
from plane.utils.global_paginator import paginate
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
issue_on_results,
|
||||
issue_queryset_grouper,
|
||||
)
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
|
||||
from .. import BaseAPIView, BaseViewSet
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.global_paginator import paginate
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.bgtasks.issue_description_version_task import issue_description_version_task
|
||||
from plane.utils.host import base_host
|
||||
|
||||
from .. import BaseAPIView, BaseViewSet
|
||||
|
||||
|
||||
class IssueListEndpoint(BaseAPIView):
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id):
|
||||
issue_ids = request.GET.get("issues", False)
|
||||
@ -82,14 +88,27 @@ class IssueListEndpoint(BaseAPIView):
|
||||
|
||||
issue_ids = [issue_id for issue_id in issue_ids.split(",") if issue_id != ""]
|
||||
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(
|
||||
# Base queryset with basic filters
|
||||
queryset = Issue.issue_objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
|
||||
# Apply filtering from filterset
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
# Apply legacy filters
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = queryset.filter(**filters)
|
||||
|
||||
# Add select_related, prefetch_related if fields or expand is not None
|
||||
if self.fields or self.expand:
|
||||
issue_queryset = issue_queryset.select_related(
|
||||
"workspace", "project", "state", "parent"
|
||||
).prefetch_related("assignees", "labels", "issue_module__module")
|
||||
|
||||
# Add annotations
|
||||
issue_queryset = (
|
||||
issue_queryset.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
@ -117,12 +136,10 @@ class IssueListEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
).distinct()
|
||||
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
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
|
||||
@ -186,6 +203,12 @@ class IssueListEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class IssueViewSet(BaseViewSet):
|
||||
model = Issue
|
||||
webhook_event = "issue"
|
||||
search_fields = ["name"]
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
def get_serializer_class(self):
|
||||
return (
|
||||
IssueCreateSerializer
|
||||
@ -193,20 +216,17 @@ class IssueViewSet(BaseViewSet):
|
||||
else IssueSerializer
|
||||
)
|
||||
|
||||
model = Issue
|
||||
webhook_event = "issue"
|
||||
|
||||
search_fields = ["name"]
|
||||
|
||||
filterset_fields = ["state__name", "assignees__id", "workspace__id"]
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.issue_objects.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
issues = Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
).distinct()
|
||||
|
||||
return issues
|
||||
|
||||
def apply_annotations(self, issues):
|
||||
issues = (
|
||||
issues.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
@ -242,6 +262,8 @@ class IssueViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
@ -250,15 +272,24 @@ class IssueViewSet(BaseViewSet):
|
||||
extra_filters = {"updated_at__gt": request.GET.get("updated_at__gt")}
|
||||
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
query_params = request.query_params.copy()
|
||||
|
||||
filters = issue_filters(query_params, "GET")
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = self.get_queryset().filter(**filters, **extra_filters)
|
||||
# Custom ordering for priority and state
|
||||
issue_queryset = self.get_queryset()
|
||||
|
||||
total_issue_queryset = Issue.issue_objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
).filter(**filters, **extra_filters)
|
||||
# Apply rich filters
|
||||
issue_queryset = self.filter_queryset(issue_queryset)
|
||||
|
||||
# Apply legacy filters
|
||||
issue_queryset = issue_queryset.filter(**filters, **extra_filters)
|
||||
|
||||
# Keeping a copy of the queryset before applying annotations
|
||||
filtered_issue_queryset = copy.deepcopy(issue_queryset)
|
||||
|
||||
# Applying annotations to the issue queryset
|
||||
issue_queryset = self.apply_annotations(issue_queryset)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
@ -292,14 +323,16 @@ class IssueViewSet(BaseViewSet):
|
||||
and not project.guest_view_all_features
|
||||
):
|
||||
issue_queryset = issue_queryset.filter(created_by=request.user)
|
||||
total_issue_queryset = total_issue_queryset.filter(created_by=request.user)
|
||||
filtered_issue_queryset = filtered_issue_queryset.filter(
|
||||
created_by=request.user
|
||||
)
|
||||
|
||||
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"
|
||||
"error": "Group by and sub group by cannot have same parameters" # noqa: E501
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@ -308,7 +341,7 @@ class IssueViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
total_count_queryset=filtered_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@ -318,12 +351,14 @@ class IssueViewSet(BaseViewSet):
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
queryset=filtered_issue_queryset,
|
||||
),
|
||||
sub_group_by_fields=issue_group_values(
|
||||
field=sub_group_by,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
queryset=filtered_issue_queryset,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
sub_group_by_field_name=sub_group_by,
|
||||
@ -342,7 +377,7 @@ class IssueViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
total_count_queryset=filtered_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@ -352,6 +387,7 @@ class IssueViewSet(BaseViewSet):
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
queryset=filtered_issue_queryset,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
count_filter=Q(
|
||||
@ -368,7 +404,7 @@ class IssueViewSet(BaseViewSet):
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
total_count_queryset=filtered_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@ -402,9 +438,11 @@ class IssueViewSet(BaseViewSet):
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
queryset = self.get_queryset()
|
||||
queryset = self.apply_annotations(queryset)
|
||||
issue = (
|
||||
issue_queryset_grouper(
|
||||
queryset=self.get_queryset().filter(pk=serializer.data["id"]),
|
||||
queryset=queryset.filter(pk=serializer.data["id"]),
|
||||
group_by=None,
|
||||
sub_group_by=None,
|
||||
)
|
||||
@ -609,9 +647,10 @@ class IssueViewSet(BaseViewSet):
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], creator=True, model=Issue
|
||||
)
|
||||
def partial_update(self, request, slug, project_id, pk=None):
|
||||
queryset = self.get_queryset()
|
||||
queryset = self.apply_annotations(queryset)
|
||||
issue = (
|
||||
self.get_queryset()
|
||||
.annotate(
|
||||
queryset.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
@ -730,6 +769,9 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
|
||||
user=request.user, project_id=project_id
|
||||
)
|
||||
|
||||
issue_property.rich_filters = request.data.get(
|
||||
"rich_filters", issue_property.rich_filters
|
||||
)
|
||||
issue_property.filters = request.data.get("filters", issue_property.filters)
|
||||
issue_property.display_filters = request.data.get(
|
||||
"display_filters", issue_property.display_filters
|
||||
@ -969,6 +1011,59 @@ class IssuePaginatedViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class IssueDetailEndpoint(BaseAPIView):
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
def apply_annotations(self, issues):
|
||||
return (
|
||||
issues.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_assignee",
|
||||
queryset=IssueAssignee.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"label_issue",
|
||||
queryset=IssueLabel.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.all(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
@ -1002,56 +1097,9 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
.values("id")
|
||||
)
|
||||
# Main issue query
|
||||
issue = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
|
||||
.filter(Exists(permission_subquery))
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_assignee",
|
||||
queryset=IssueAssignee.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"label_issue",
|
||||
queryset=IssueLabel.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.all(),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
issue = Issue.issue_objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).filter(Exists(permission_subquery))
|
||||
|
||||
# Add additional prefetch based on expand parameter
|
||||
if self.expand:
|
||||
@ -1070,8 +1118,20 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
|
||||
# Apply filtering from filterset
|
||||
issue = self.filter_queryset(issue)
|
||||
|
||||
# Apply legacy filters
|
||||
issue = issue.filter(**filters)
|
||||
|
||||
# Total count queryset
|
||||
total_issue_queryset = copy.deepcopy(issue)
|
||||
|
||||
# Applying annotations to the issue queryset
|
||||
issue = self.apply_annotations(issue)
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
# Issue queryset
|
||||
issue, order_by_param = order_issue_queryset(
|
||||
issue_queryset=issue, order_by_param=order_by_param
|
||||
@ -1079,7 +1139,8 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=(issue),
|
||||
queryset=issue,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issue: IssueListDetailSerializer(
|
||||
issue, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
|
||||
@ -904,6 +904,9 @@ class ModuleUserPropertiesEndpoint(BaseAPIView):
|
||||
module_properties.filters = request.data.get(
|
||||
"filters", module_properties.filters
|
||||
)
|
||||
module_properties.rich_filters = request.data.get(
|
||||
"rich_filters", module_properties.rich_filters
|
||||
)
|
||||
module_properties.display_filters = request.data.get(
|
||||
"display_filters", module_properties.display_filters
|
||||
)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
# Python imports
|
||||
import copy
|
||||
import json
|
||||
|
||||
from django.db.models import F, Func, OuterRef, Q, Subquery
|
||||
@ -31,8 +32,8 @@ from plane.utils.grouper import (
|
||||
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 plane.utils.filters import ComplexFilterBackend
|
||||
from plane.utils.filters import IssueFilterSet
|
||||
from .. import BaseViewSet
|
||||
from plane.utils.host import base_host
|
||||
|
||||
@ -42,20 +43,12 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
model = ModuleIssue
|
||||
webhook_event = "module_issue"
|
||||
bulk = True
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
filterset_fields = ["issue__labels__id", "issue__assignees__id"]
|
||||
|
||||
def get_queryset(self):
|
||||
def apply_annotations(self, issues):
|
||||
return (
|
||||
Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
issue_module__module_id=self.kwargs.get("module_id"),
|
||||
issue_module__deleted_at__isnull=True,
|
||||
)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
issues.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
@ -83,13 +76,37 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
issue_module__module_id=self.kwargs.get("module_id"),
|
||||
issue_module__deleted_at__isnull=True,
|
||||
)
|
||||
).distinct()
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def list(self, request, slug, project_id, module_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
issue_queryset = self.get_queryset()
|
||||
|
||||
# Apply filtering from filterset
|
||||
issue_queryset = self.filter_queryset(issue_queryset)
|
||||
|
||||
# Apply legacy filters
|
||||
issue_queryset = issue_queryset.filter(**filters)
|
||||
|
||||
# Total count queryset
|
||||
total_issue_queryset = copy.deepcopy(issue_queryset)
|
||||
|
||||
# Apply annotations to the issue queryset
|
||||
issue_queryset = self.apply_annotations(issue_queryset)
|
||||
|
||||
order_by_param = request.GET.get("order_by", "created_at")
|
||||
|
||||
# Issue queryset
|
||||
@ -122,6 +139,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@ -131,12 +149,14 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
queryset=total_issue_queryset,
|
||||
),
|
||||
sub_group_by_fields=issue_group_values(
|
||||
field=sub_group_by,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
queryset=total_issue_queryset,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
sub_group_by_field_name=sub_group_by,
|
||||
@ -156,6 +176,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@ -165,6 +186,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
filters=filters,
|
||||
queryset=total_issue_queryset,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
count_filter=Q(
|
||||
@ -182,6 +204,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@ -282,9 +305,11 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"module_name": module_issue.first().module.name
|
||||
"module_name": (
|
||||
module_issue.first().module.name
|
||||
if (module_issue.first() and module_issue.first().module)
|
||||
else None
|
||||
)
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import copy
|
||||
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Exists,
|
||||
@ -39,6 +41,8 @@ from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from .. import BaseViewSet
|
||||
from plane.db.models import UserFavorite
|
||||
from plane.utils.filters import ComplexFilterBackend
|
||||
from plane.utils.filters import IssueFilterSet
|
||||
|
||||
|
||||
class WorkspaceViewViewSet(BaseViewSet):
|
||||
@ -56,7 +60,6 @@ class WorkspaceViewViewSet(BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project__isnull=True)
|
||||
.filter(Q(owned_by=self.request.user) | Q(access=1))
|
||||
.select_related("workspace")
|
||||
.order_by(self.request.GET.get("order_by", "-created_at"))
|
||||
.distinct()
|
||||
)
|
||||
@ -145,6 +148,9 @@ class WorkspaceViewViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
def _get_project_permission_filters(self):
|
||||
"""
|
||||
Get common project permission filters for guest users and role-based access control.
|
||||
@ -167,35 +173,9 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
def apply_annotations(self, issues):
|
||||
return (
|
||||
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(workspace__slug=self.kwargs.get("slug"))
|
||||
.select_related("state")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_assignee",
|
||||
queryset=IssueAssignee.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"label_issue",
|
||||
queryset=IssueLabel.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.all(),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
issues.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
@ -223,32 +203,57 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_assignee",
|
||||
queryset=IssueAssignee.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"label_issue",
|
||||
queryset=IssueLabel.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.all(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return Issue.issue_objects.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
def list(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = self.get_queryset()
|
||||
|
||||
# Apply filtering from filterset
|
||||
issue_queryset = self.filter_queryset(issue_queryset)
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
# Apply legacy filters
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_queryset = issue_queryset.filter(**filters)
|
||||
|
||||
# Get common project permission filters
|
||||
permission_filters = self._get_project_permission_filters()
|
||||
|
||||
# Base query for the counts
|
||||
total_issue_count = (
|
||||
Issue.issue_objects.filter(**filters)
|
||||
.filter(workspace__slug=slug)
|
||||
.filter(permission_filters)
|
||||
.only("id")
|
||||
)
|
||||
|
||||
# Apply project permission filters to the issue queryset
|
||||
issue_queryset = issue_queryset.filter(permission_filters)
|
||||
|
||||
# Base query for the counts
|
||||
total_issue_count_queryset = copy.deepcopy(issue_queryset)
|
||||
total_issue_count_queryset = total_issue_count_queryset.only("id")
|
||||
|
||||
# Apply annotations to the issue queryset
|
||||
issue_queryset = self.apply_annotations(issue_queryset)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
issue_queryset=issue_queryset, order_by_param=order_by_param
|
||||
@ -260,7 +265,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data,
|
||||
total_count_queryset=total_issue_count,
|
||||
total_count_queryset=total_issue_count_queryset,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
# Python imports
|
||||
import copy
|
||||
from datetime import date
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
@ -56,6 +57,8 @@ from plane.utils.grouper import (
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
|
||||
from plane.utils.filters import ComplexFilterBackend
|
||||
from plane.utils.filters import IssueFilterSet
|
||||
|
||||
|
||||
class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
|
||||
@ -91,23 +94,12 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
|
||||
class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceViewerPermission]
|
||||
|
||||
def get(self, request, slug, user_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
filter_backends = (ComplexFilterBackend,)
|
||||
filterset_class = IssueFilterSet
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
issue_queryset = (
|
||||
Issue.issue_objects.filter(
|
||||
Q(assignees__in=[user_id])
|
||||
| Q(created_by_id=user_id)
|
||||
| Q(issue_subscribers__subscriber_id=user_id),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
def apply_annotations(self, issues):
|
||||
return (
|
||||
issues.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
@ -135,8 +127,36 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.order_by("created_at")
|
||||
).distinct()
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
)
|
||||
|
||||
def get(self, request, slug, user_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
issue_queryset = Issue.issue_objects.filter(
|
||||
id__in=Issue.issue_objects.filter(
|
||||
Q(assignees__in=[user_id])
|
||||
| Q(created_by_id=user_id)
|
||||
| Q(issue_subscribers__subscriber_id=user_id),
|
||||
workspace__slug=slug,
|
||||
).values_list("id", flat=True),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
|
||||
# Apply filtering from filterset
|
||||
issue_queryset = self.filter_queryset(issue_queryset)
|
||||
|
||||
# Apply legacy filters
|
||||
issue_queryset = issue_queryset.filter(**filters)
|
||||
|
||||
# Total count queryset
|
||||
total_issue_queryset = copy.deepcopy(issue_queryset)
|
||||
|
||||
# Apply annotations to the issue queryset
|
||||
issue_queryset = self.apply_annotations(issue_queryset)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
@ -157,7 +177,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
if group_by == sub_group_by:
|
||||
return Response(
|
||||
{
|
||||
"error": "Group by and sub group by cannot have same parameters"
|
||||
"error": "Group by and sub group by cannot have same parameters" # noqa: E501
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@ -166,15 +186,22 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_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, filters=filters
|
||||
field=group_by,
|
||||
slug=slug,
|
||||
filters=filters,
|
||||
queryset=total_issue_queryset,
|
||||
),
|
||||
sub_group_by_fields=issue_group_values(
|
||||
field=sub_group_by, slug=slug, filters=filters
|
||||
field=sub_group_by,
|
||||
slug=slug,
|
||||
filters=filters,
|
||||
queryset=total_issue_queryset,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
sub_group_by_field_name=sub_group_by,
|
||||
@ -193,12 +220,16 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_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, filters=filters
|
||||
field=group_by,
|
||||
slug=slug,
|
||||
filters=filters,
|
||||
queryset=total_issue_queryset,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
count_filter=Q(
|
||||
@ -215,6 +246,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
total_count_queryset=total_issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
@ -232,6 +264,9 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView):
|
||||
workspace_properties.filters = request.data.get(
|
||||
"filters", workspace_properties.filters
|
||||
)
|
||||
workspace_properties.rich_filters = request.data.get(
|
||||
"rich_filters", workspace_properties.rich_filters
|
||||
)
|
||||
workspace_properties.display_filters = request.data.get(
|
||||
"display_filters", workspace_properties.display_filters
|
||||
)
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
from django.db import migrations
|
||||
|
||||
from plane.utils.filters import LegacyToRichFiltersConverter
|
||||
from plane.utils.filters.filter_migrations import (
|
||||
migrate_models_filters_to_rich_filters,
|
||||
clear_models_rich_filters,
|
||||
)
|
||||
|
||||
|
||||
# Define all models that need migration in one place
|
||||
MODEL_NAMES = [
|
||||
"IssueView",
|
||||
"WorkspaceUserProperties",
|
||||
"ModuleUserProperties",
|
||||
"IssueUserProperty",
|
||||
"CycleUserProperties",
|
||||
]
|
||||
|
||||
|
||||
def migrate_filters_to_rich_filters(apps, schema_editor):
|
||||
"""
|
||||
Migrate legacy filters to rich_filters format for all models that have both fields.
|
||||
"""
|
||||
# Get the model classes from model names
|
||||
models_to_migrate = {}
|
||||
|
||||
for model_name in MODEL_NAMES:
|
||||
try:
|
||||
model_class = apps.get_model("db", model_name)
|
||||
models_to_migrate[model_name] = model_class
|
||||
except Exception as e:
|
||||
# Log error but continue with other models
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to get model {model_name}: {str(e)}")
|
||||
|
||||
converter = LegacyToRichFiltersConverter()
|
||||
# Migrate all models
|
||||
migrate_models_filters_to_rich_filters(models_to_migrate, converter)
|
||||
|
||||
|
||||
def reverse_migrate_rich_filters_to_filters(apps, schema_editor):
|
||||
"""
|
||||
Reverse migration to clear rich_filters field for all models.
|
||||
"""
|
||||
# Get the model classes from model names
|
||||
models_to_clear = {}
|
||||
|
||||
for model_name in MODEL_NAMES:
|
||||
try:
|
||||
model_class = apps.get_model("db", model_name)
|
||||
models_to_clear[model_name] = model_class
|
||||
except Exception as e:
|
||||
# Log error but continue with other models
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to get model {model_name}: {str(e)}")
|
||||
|
||||
# Clear rich_filters for all models
|
||||
clear_models_rich_filters(models_to_clear)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0106_auto_20250912_0845'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
migrate_filters_to_rich_filters,
|
||||
reverse_code=reverse_migrate_rich_filters_to_filters,
|
||||
),
|
||||
]
|
||||
@ -184,6 +184,7 @@ def issue_group_values(
|
||||
slug: str,
|
||||
project_id: Optional[str] = None,
|
||||
filters: Dict[str, Any] = {},
|
||||
queryset: Optional[QuerySet] = None,
|
||||
) -> List[Union[str, Any]]:
|
||||
if field == "state_id":
|
||||
queryset = State.objects.filter(
|
||||
@ -238,35 +239,20 @@ def issue_group_values(
|
||||
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()
|
||||
)
|
||||
queryset = queryset.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()
|
||||
)
|
||||
queryset = queryset.values_list("start_date", flat=True).distinct()
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
|
||||
if field == "created_by":
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug)
|
||||
.filter(**filters)
|
||||
.values_list("created_by", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
queryset = queryset.values_list("created_by", flat=True).distinct()
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
|
||||
10
apps/api/plane/utils/filters/__init__.py
Normal file
10
apps/api/plane/utils/filters/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
# Filters module for handling complex filtering operations
|
||||
|
||||
# Import all utilities from base modules
|
||||
from .filter_backend import ComplexFilterBackend
|
||||
from .converters import LegacyToRichFiltersConverter
|
||||
from .filterset import IssueFilterSet
|
||||
|
||||
|
||||
# Public API exports
|
||||
__all__ = ["ComplexFilterBackend", "LegacyToRichFiltersConverter", "IssueFilterSet"]
|
||||
438
apps/api/plane/utils/filters/converters.py
Normal file
438
apps/api/plane/utils/filters/converters.py
Normal file
@ -0,0 +1,438 @@
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from dateutil.parser import parse as dateutil_parse
|
||||
|
||||
|
||||
class LegacyToRichFiltersConverter:
|
||||
# Default mapping from legacy filter names to new rich filter field names
|
||||
DEFAULT_FIELD_MAPPINGS = {
|
||||
"state": "state_id",
|
||||
"labels": "label_id",
|
||||
"cycle": "cycle_id",
|
||||
"module": "module_id",
|
||||
"assignees": "assignee_id",
|
||||
"mentions": "mention_id",
|
||||
"created_by": "created_by_id",
|
||||
"state_group": "state_group",
|
||||
"priority": "priority",
|
||||
"project": "project_id",
|
||||
"start_date": "start_date",
|
||||
"target_date": "target_date",
|
||||
}
|
||||
|
||||
# Default fields that expect UUID values
|
||||
DEFAULT_UUID_FIELDS = {
|
||||
"state_id",
|
||||
"label_id",
|
||||
"cycle_id",
|
||||
"module_id",
|
||||
"assignee_id",
|
||||
"mention_id",
|
||||
"created_by_id",
|
||||
"project_id",
|
||||
}
|
||||
|
||||
# Default valid choices for choice fields
|
||||
DEFAULT_VALID_CHOICES = {
|
||||
"state_group": ["backlog", "unstarted", "started", "completed", "cancelled"],
|
||||
"priority": ["urgent", "high", "medium", "low", "none"],
|
||||
}
|
||||
|
||||
# Default date fields
|
||||
DEFAULT_DATE_FIELDS = {"start_date", "target_date"}
|
||||
|
||||
# Pattern for relative date strings like "2_weeks" or "3_months"
|
||||
DATE_PATTERN = re.compile(r"(\d+)_(weeks|months)$")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
field_mappings: Dict[str, str] = None,
|
||||
uuid_fields: set = None,
|
||||
valid_choices: Dict[str, List[str]] = None,
|
||||
date_fields: set = None,
|
||||
extend_defaults: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize the converter with optional custom configurations.
|
||||
|
||||
Args:
|
||||
field_mappings: Custom field mappings (legacy_key -> rich_field_name)
|
||||
uuid_fields: Set of field names that should be validated as UUIDs
|
||||
valid_choices: Dict of valid choices for choice fields
|
||||
date_fields: Set of field names that should be treated as dates
|
||||
extend_defaults: If True, merge with defaults; if False, replace defaults
|
||||
|
||||
Examples:
|
||||
# Use defaults
|
||||
converter = LegacyToRichFiltersConverter()
|
||||
|
||||
# Add custom field mapping
|
||||
converter = LegacyToRichFiltersConverter(
|
||||
field_mappings={"custom_field": "custom_field_id"}
|
||||
)
|
||||
|
||||
# Override priority choices
|
||||
converter = LegacyToRichFiltersConverter(
|
||||
valid_choices={"priority": ["critical", "high", "medium", "low"]}
|
||||
)
|
||||
|
||||
# Complete replacement (not extending defaults)
|
||||
converter = LegacyToRichFiltersConverter(
|
||||
field_mappings={"state": "status_id"},
|
||||
extend_defaults=False
|
||||
)
|
||||
"""
|
||||
if extend_defaults:
|
||||
# Merge with defaults
|
||||
self.FIELD_MAPPINGS = {**self.DEFAULT_FIELD_MAPPINGS}
|
||||
if field_mappings:
|
||||
self.FIELD_MAPPINGS.update(field_mappings)
|
||||
|
||||
self.UUID_FIELDS = {*self.DEFAULT_UUID_FIELDS}
|
||||
if uuid_fields:
|
||||
self.UUID_FIELDS.update(uuid_fields)
|
||||
|
||||
self.VALID_CHOICES = {**self.DEFAULT_VALID_CHOICES}
|
||||
if valid_choices:
|
||||
self.VALID_CHOICES.update(valid_choices)
|
||||
|
||||
self.DATE_FIELDS = {*self.DEFAULT_DATE_FIELDS}
|
||||
if date_fields:
|
||||
self.DATE_FIELDS.update(date_fields)
|
||||
else:
|
||||
# Replace defaults entirely
|
||||
self.FIELD_MAPPINGS = field_mappings or {}
|
||||
self.UUID_FIELDS = uuid_fields or set()
|
||||
self.VALID_CHOICES = valid_choices or {}
|
||||
self.DATE_FIELDS = date_fields or set()
|
||||
|
||||
def add_field_mapping(self, legacy_key: str, rich_field_name: str) -> None:
|
||||
"""Add or update a single field mapping."""
|
||||
self.FIELD_MAPPINGS[legacy_key] = rich_field_name
|
||||
|
||||
def add_uuid_field(self, field_name: str) -> None:
|
||||
"""Add a field that should be validated as UUID."""
|
||||
self.UUID_FIELDS.add(field_name)
|
||||
|
||||
def add_choice_field(self, field_name: str, choices: List[str]) -> None:
|
||||
"""Add or update valid choices for a choice field."""
|
||||
self.VALID_CHOICES[field_name] = choices
|
||||
|
||||
def add_date_field(self, field_name: str) -> None:
|
||||
"""Add a field that should be treated as a date field."""
|
||||
self.DATE_FIELDS.add(field_name)
|
||||
|
||||
def update_mappings(
|
||||
self,
|
||||
field_mappings: Dict[str, str] = None,
|
||||
uuid_fields: set = None,
|
||||
valid_choices: Dict[str, List[str]] = None,
|
||||
date_fields: set = None,
|
||||
) -> None:
|
||||
"""
|
||||
Update multiple configurations at once.
|
||||
|
||||
Args:
|
||||
field_mappings: Additional field mappings to add/update
|
||||
uuid_fields: Additional UUID fields to add
|
||||
valid_choices: Additional choice fields to add/update
|
||||
date_fields: Additional date fields to add
|
||||
"""
|
||||
if field_mappings:
|
||||
self.FIELD_MAPPINGS.update(field_mappings)
|
||||
if uuid_fields:
|
||||
self.UUID_FIELDS.update(uuid_fields)
|
||||
if valid_choices:
|
||||
self.VALID_CHOICES.update(valid_choices)
|
||||
if date_fields:
|
||||
self.DATE_FIELDS.update(date_fields)
|
||||
|
||||
def _validate_uuid(self, value: str) -> bool:
|
||||
"""Validate if a string is a valid UUID"""
|
||||
try:
|
||||
uuid.UUID(str(value))
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
def _validate_choice(self, field_name: str, value: str) -> bool:
|
||||
"""Validate if a value is valid for a choice field"""
|
||||
if field_name not in self.VALID_CHOICES:
|
||||
return True # No validation needed for this field
|
||||
return value in self.VALID_CHOICES[field_name]
|
||||
|
||||
def _validate_date(self, value: Union[str, datetime]) -> bool:
|
||||
"""Validate if a value is a valid date using dateutil parser"""
|
||||
if isinstance(value, datetime):
|
||||
return True
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
# Use dateutil for flexible date parsing
|
||||
dateutil_parse(value)
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return False
|
||||
|
||||
def _validate_value(self, rich_field_name: str, value: Any) -> bool:
|
||||
"""Validate a single value based on field type"""
|
||||
if rich_field_name in self.UUID_FIELDS:
|
||||
return self._validate_uuid(value)
|
||||
elif rich_field_name in self.VALID_CHOICES:
|
||||
return self._validate_choice(rich_field_name, value)
|
||||
elif rich_field_name in self.DATE_FIELDS:
|
||||
return self._validate_date(value)
|
||||
return True # No specific validation needed
|
||||
|
||||
def _filter_valid_values(
|
||||
self, rich_field_name: str, values: List[Any]
|
||||
) -> List[Any]:
|
||||
"""Filter out invalid values from a list and return only valid ones"""
|
||||
valid_values = []
|
||||
for value in values:
|
||||
if self._validate_value(rich_field_name, value):
|
||||
valid_values.append(value)
|
||||
return valid_values
|
||||
|
||||
def _add_validation_error(
|
||||
self, strict: bool, validation_errors: List[str], message: str
|
||||
) -> None:
|
||||
"""Add validation error if in strict mode."""
|
||||
if strict:
|
||||
validation_errors.append(message)
|
||||
|
||||
def _add_rich_filter(
|
||||
self, rich_filters: Dict[str, Any], field_name: str, operator: str, value: Any
|
||||
) -> None:
|
||||
"""Add a rich filter with proper field name formatting."""
|
||||
# Convert lists to comma-separated strings for 'in' and 'range' operations
|
||||
if operator in ("in", "range") and isinstance(value, list):
|
||||
value = ",".join(str(v) for v in value)
|
||||
rich_filters[f"{field_name}__{operator}"] = value
|
||||
|
||||
def _handle_value_error(
|
||||
self, e: ValueError, strict: bool, validation_errors: List[str]
|
||||
) -> None:
|
||||
"""Handle ValueError with consistent strict/non-strict behavior."""
|
||||
if strict:
|
||||
validation_errors.append(str(e))
|
||||
# In non-strict mode, we just skip (no action needed)
|
||||
|
||||
def _process_date_field(
|
||||
self,
|
||||
rich_field_name: str,
|
||||
values: List[str],
|
||||
strict: bool,
|
||||
validation_errors: List[str],
|
||||
rich_filters: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Process date field with basic functionality (exact, range)."""
|
||||
if rich_field_name not in self.DATE_FIELDS:
|
||||
return False
|
||||
|
||||
try:
|
||||
date_filter_result = self._convert_date_value(
|
||||
rich_field_name, values, strict
|
||||
)
|
||||
if date_filter_result:
|
||||
rich_filters.update(date_filter_result)
|
||||
return True
|
||||
except ValueError as e:
|
||||
self._handle_value_error(e, strict, validation_errors)
|
||||
return True
|
||||
|
||||
def _convert_date_value(
|
||||
self, field_name: str, values: List[str], strict: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert legacy date values to rich filter format - basic implementation.
|
||||
|
||||
Supports:
|
||||
- Simple dates: "2023-01-01" -> __exact
|
||||
- Basic ranges: ["2023-01-01;after", "2023-12-31;before"] -> __range
|
||||
- Skips complex or relative date patterns
|
||||
|
||||
Args:
|
||||
field_name: Name of the rich filter field
|
||||
values: List of legacy date values
|
||||
strict: If True, raise errors for validation failures
|
||||
|
||||
Raises:
|
||||
ValueError: For malformed date patterns (strict mode)
|
||||
"""
|
||||
# Check for relative dates and skip the entire field if found
|
||||
for value in values:
|
||||
if ";" in value:
|
||||
parts = value.split(";")
|
||||
if len(parts) > 0 and self.DATE_PATTERN.match(parts[0]):
|
||||
# Skip relative date patterns entirely
|
||||
return {}
|
||||
|
||||
# Skip complex conditions (more than 2 values)
|
||||
if len(values) > 2:
|
||||
return {}
|
||||
|
||||
# Process each date value
|
||||
exact_dates = []
|
||||
after_dates = []
|
||||
before_dates = []
|
||||
|
||||
for value in values:
|
||||
if ";" not in value:
|
||||
# Simple date string
|
||||
if not self._validate_date(value):
|
||||
if strict:
|
||||
raise ValueError(f"Invalid date format: {value}")
|
||||
continue
|
||||
exact_dates.append(value)
|
||||
else:
|
||||
# Directional date - only handle basic after/before
|
||||
parts = value.split(";")
|
||||
if len(parts) < 2:
|
||||
if strict:
|
||||
raise ValueError(f"Invalid date format: {value}")
|
||||
continue
|
||||
|
||||
date_part = parts[0]
|
||||
direction = parts[1]
|
||||
|
||||
if not self._validate_date(date_part):
|
||||
if strict:
|
||||
raise ValueError(f"Invalid date format: {date_part}")
|
||||
continue
|
||||
|
||||
if direction == "after":
|
||||
after_dates.append(date_part)
|
||||
elif direction == "before":
|
||||
before_dates.append(date_part)
|
||||
# Skip unsupported directions
|
||||
|
||||
# Determine return format
|
||||
result = {}
|
||||
if len(after_dates) == 1 and len(before_dates) == 1 and len(exact_dates) == 0:
|
||||
# Simple range: one after and one before
|
||||
start_date = min(after_dates[0], before_dates[0])
|
||||
end_date = max(after_dates[0], before_dates[0])
|
||||
self._add_rich_filter(result, field_name, "range", [start_date, end_date])
|
||||
elif len(exact_dates) == 1 and len(after_dates) == 0 and len(before_dates) == 0:
|
||||
# Single exact date
|
||||
self._add_rich_filter(result, field_name, "exact", exact_dates[0])
|
||||
# Skip all other combinations
|
||||
|
||||
return result
|
||||
|
||||
def convert(self, legacy_filters: dict, strict: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert legacy filters to rich filters format with validation
|
||||
|
||||
Args:
|
||||
legacy_filters: Dictionary of legacy filters
|
||||
strict: If True, raise exception on validation errors.
|
||||
If False, skip invalid values (default behavior)
|
||||
|
||||
Returns:
|
||||
Dictionary of rich filters
|
||||
|
||||
Raises:
|
||||
ValueError: If strict=True and validation fails
|
||||
"""
|
||||
rich_filters = {}
|
||||
validation_errors = []
|
||||
|
||||
for legacy_key, value in legacy_filters.items():
|
||||
# Skip if value is None or empty
|
||||
if value is None or (isinstance(value, list) and len(value) == 0):
|
||||
continue
|
||||
|
||||
# Skip if legacy key is not in our mappings (not supported in filterset)
|
||||
if legacy_key not in self.FIELD_MAPPINGS:
|
||||
self._add_validation_error(
|
||||
strict, validation_errors, f"Unsupported filter key: {legacy_key}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Get the new field name
|
||||
rich_field_name = self.FIELD_MAPPINGS[legacy_key]
|
||||
|
||||
# Handle list values
|
||||
if isinstance(value, list):
|
||||
# Process date fields with helper method
|
||||
if self._process_date_field(
|
||||
rich_field_name, value, strict, validation_errors, rich_filters
|
||||
):
|
||||
continue
|
||||
|
||||
# Regular non-date field processing
|
||||
# Filter out invalid values
|
||||
valid_values = self._filter_valid_values(rich_field_name, value)
|
||||
|
||||
if not valid_values:
|
||||
self._add_validation_error(
|
||||
strict,
|
||||
validation_errors,
|
||||
f"No valid values found for {legacy_key}: {value}",
|
||||
)
|
||||
continue
|
||||
|
||||
# Check for invalid values if in strict mode
|
||||
if strict and len(valid_values) != len(value):
|
||||
invalid_values = [v for v in value if v not in valid_values]
|
||||
self._add_validation_error(
|
||||
strict,
|
||||
validation_errors,
|
||||
f"Invalid values for {legacy_key}: {invalid_values}",
|
||||
)
|
||||
|
||||
# For list values, always use __in operator for non-date fields
|
||||
self._add_rich_filter(rich_filters, rich_field_name, "in", valid_values)
|
||||
|
||||
else:
|
||||
# Handle single values
|
||||
# Process date fields with helper method
|
||||
if self._process_date_field(
|
||||
rich_field_name, [value], strict, validation_errors, rich_filters
|
||||
):
|
||||
continue
|
||||
|
||||
# For non-list values, use __exact operator for non-date fields
|
||||
if self._validate_value(rich_field_name, value):
|
||||
self._add_rich_filter(rich_filters, rich_field_name, "exact", value)
|
||||
else:
|
||||
error_msg = f"Invalid value for {legacy_key}: {value}"
|
||||
self._add_validation_error(strict, validation_errors, error_msg)
|
||||
|
||||
# Raise validation errors if in strict mode
|
||||
if strict and validation_errors:
|
||||
error_message = f"Filter validation errors: {'; '.join(validation_errors)}"
|
||||
raise ValueError(error_message)
|
||||
|
||||
# Convert flat dict to rich filter format
|
||||
return self._format_as_rich_filter(rich_filters)
|
||||
|
||||
def _format_as_rich_filter(self, flat_filters: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert a flat dictionary of filters to the proper rich filter format.
|
||||
|
||||
Args:
|
||||
flat_filters: Dictionary with field__lookup keys and values
|
||||
|
||||
Returns:
|
||||
Rich filter format using logical operators (and/or/not)
|
||||
"""
|
||||
if not flat_filters:
|
||||
return {}
|
||||
|
||||
# If only one filter, return as leaf node
|
||||
if len(flat_filters) == 1:
|
||||
key, value = next(iter(flat_filters.items()))
|
||||
return {key: value}
|
||||
|
||||
# Multiple filters: wrap in 'and' operator
|
||||
filter_conditions = []
|
||||
for key, value in flat_filters.items():
|
||||
filter_conditions.append({key: value})
|
||||
|
||||
return {"and": filter_conditions}
|
||||
380
apps/api/plane/utils/filters/filter_backend.py
Normal file
380
apps/api/plane/utils/filters/filter_backend.py
Normal file
@ -0,0 +1,380 @@
|
||||
import json
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import QueryDict
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters
|
||||
|
||||
|
||||
class ComplexFilterBackend(filters.BaseFilterBackend):
|
||||
"""
|
||||
Filter backend that supports complex JSON filtering.
|
||||
|
||||
For full, up-to-date examples and usage, see the package README
|
||||
at `plane/utils/filters/README.md`.
|
||||
"""
|
||||
|
||||
filter_param = "filters"
|
||||
default_max_depth = 5
|
||||
|
||||
def filter_queryset(self, request, queryset, view, filter_data=None):
|
||||
"""Normalize filter input and apply JSON-based filtering.
|
||||
|
||||
Accepts explicit `filter_data` (dict or JSON string) or reads the
|
||||
`filter` query parameter. Enforces JSON-only filtering.
|
||||
"""
|
||||
try:
|
||||
if filter_data is not None:
|
||||
normalized = self._normalize_filter_data(filter_data, "filter_data")
|
||||
return self._apply_json_filter(queryset, normalized, view)
|
||||
|
||||
filter_string = request.query_params.get(self.filter_param, None)
|
||||
if not filter_string:
|
||||
return queryset
|
||||
|
||||
normalized = self._normalize_filter_data(filter_string, "filter")
|
||||
return self._apply_json_filter(queryset, normalized, view)
|
||||
except ValidationError:
|
||||
# Propagate validation errors unchanged
|
||||
raise
|
||||
except Exception as e:
|
||||
raise
|
||||
# Convert unexpected errors to ValidationError to keep response consistent
|
||||
raise ValidationError(f"Filter error: {str(e)}")
|
||||
|
||||
def _normalize_filter_data(self, raw_filter, source_label):
|
||||
"""Return a dict from raw filter input or raise a ValidationError.
|
||||
|
||||
- raw_filter may be a dict or a JSON string
|
||||
- source_label is used in error messages (e.g., 'filter_data' or 'filter')
|
||||
"""
|
||||
try:
|
||||
if isinstance(raw_filter, str):
|
||||
return json.loads(raw_filter)
|
||||
if isinstance(raw_filter, dict):
|
||||
return raw_filter
|
||||
raise ValidationError(f"'{source_label}' must be a dict or a JSON string.")
|
||||
except json.JSONDecodeError:
|
||||
raise ValidationError(
|
||||
f"Invalid JSON for '{source_label}'. Expected a valid JSON object."
|
||||
)
|
||||
|
||||
def _apply_json_filter(self, queryset, filter_data, view):
|
||||
"""Process a JSON filter structure using OR/AND/NOT set operations."""
|
||||
if not filter_data:
|
||||
return queryset
|
||||
|
||||
# Validate structure and depth before field allowlist checks
|
||||
max_depth = self._get_max_depth(view)
|
||||
self._validate_structure(filter_data, max_depth=max_depth, current_depth=1)
|
||||
|
||||
# Validate against the view's FilterSet (only declared filters are allowed)
|
||||
self._validate_fields(filter_data, view)
|
||||
|
||||
# Build combined queryset using FilterSet-driven leaf evaluation
|
||||
combined_qs = self._evaluate_node(filter_data, queryset, view)
|
||||
if combined_qs is None:
|
||||
return queryset
|
||||
return combined_qs
|
||||
|
||||
def _validate_fields(self, filter_data, view):
|
||||
"""Validate that filtered fields are defined in the view's FilterSet."""
|
||||
filterset_class = getattr(view, "filterset_class", None)
|
||||
allowed_fields = (
|
||||
set(filterset_class.base_filters.keys()) if filterset_class else None
|
||||
)
|
||||
if not allowed_fields:
|
||||
# If no FilterSet is configured, reject filtering to avoid unintended exposure # noqa: E501
|
||||
raise ValidationError(
|
||||
"Filtering is not enabled for this endpoint (missing filterset_class)"
|
||||
)
|
||||
|
||||
# Extract field names from the filter data
|
||||
fields = self._extract_field_names(filter_data)
|
||||
|
||||
# Check if all fields are allowed
|
||||
for field in fields:
|
||||
# Field keys must match FilterSet filter names (including any lookups)
|
||||
# Example: 'sequence_id__gte' should be declared in base_filters
|
||||
# Special-case __range: require the '<base>__range' filter itself
|
||||
if field not in allowed_fields:
|
||||
raise ValidationError(f"Filtering on field '{field}' is not allowed")
|
||||
|
||||
def _extract_field_names(self, filter_data):
|
||||
"""Extract all field names from a nested filter structure"""
|
||||
if isinstance(filter_data, dict):
|
||||
fields = []
|
||||
for key, value in filter_data.items():
|
||||
if key.lower() in ("or", "and", "not"):
|
||||
# This is a logical operator, process its children
|
||||
if key.lower() == "not":
|
||||
# 'not' has a dict as its value, not a list
|
||||
if isinstance(value, dict):
|
||||
fields.extend(self._extract_field_names(value))
|
||||
else:
|
||||
# 'or' and 'and' have lists as their values
|
||||
for item in value:
|
||||
fields.extend(self._extract_field_names(item))
|
||||
else:
|
||||
# This is a field name
|
||||
fields.append(key)
|
||||
return fields
|
||||
return []
|
||||
|
||||
def _evaluate_node(self, node, base_queryset, view):
|
||||
"""
|
||||
Recursively evaluate a JSON node into a combined queryset using branch-based filtering.
|
||||
|
||||
Rules:
|
||||
- leaf dict → evaluated through DjangoFilterBackend as a mini-querystring
|
||||
- {"or": [...]} → union (|) of children
|
||||
- {"and": [...]} → collect field conditions per branch and apply together
|
||||
- {"not": {...}} → exclude child's rows from the base queryset
|
||||
(complement within base scope)
|
||||
"""
|
||||
if not isinstance(node, dict):
|
||||
return None
|
||||
|
||||
# 'or' combination - requires set operations between children
|
||||
if "or" in node:
|
||||
children = node["or"]
|
||||
if not isinstance(children, list) or not children:
|
||||
return None
|
||||
combined = None
|
||||
for child in children:
|
||||
child_qs = self._evaluate_node(child, base_queryset, view)
|
||||
if child_qs is None:
|
||||
continue
|
||||
combined = child_qs if combined is None else (combined | child_qs)
|
||||
return combined
|
||||
|
||||
# 'and' combination - collect field conditions per branch
|
||||
if "and" in node:
|
||||
children = node["and"]
|
||||
if not isinstance(children, list) or not children:
|
||||
return None
|
||||
return self._evaluate_and_branch(children, base_queryset, view)
|
||||
|
||||
# 'not' negation
|
||||
if "not" in node:
|
||||
child = node["not"]
|
||||
if not isinstance(child, dict):
|
||||
return None
|
||||
child_qs = self._evaluate_node(child, base_queryset, view)
|
||||
if child_qs is None:
|
||||
return None
|
||||
# Use subquery instead of pk__in for better performance
|
||||
# This avoids evaluating child_qs and creating large IN clauses
|
||||
return base_queryset.exclude(pk__in=child_qs.values("pk"))
|
||||
|
||||
# Leaf dict: evaluate via DjangoFilterBackend using FilterSet
|
||||
return self._filter_leaf_via_backend(node, base_queryset, view)
|
||||
|
||||
def _evaluate_and_branch(self, children, base_queryset, view):
|
||||
"""
|
||||
Evaluate an AND branch by collecting field conditions and applying them together.
|
||||
|
||||
This approach is more efficient than individual leaf evaluation because:
|
||||
- Field conditions within the same AND branch are collected and applied together
|
||||
- Only logical operation children require separate evaluation and set intersection
|
||||
- Reduces the number of intermediate querysets and database queries
|
||||
"""
|
||||
collected_conditions = {}
|
||||
logical_querysets = []
|
||||
|
||||
# Separate field conditions from logical operations
|
||||
for child in children:
|
||||
if not isinstance(child, dict):
|
||||
continue
|
||||
|
||||
# Check if this child contains logical operators
|
||||
has_logical = any(
|
||||
k.lower() in ("or", "and", "not")
|
||||
for k in child.keys()
|
||||
if isinstance(k, str)
|
||||
)
|
||||
|
||||
if has_logical:
|
||||
# This child has logical operators, evaluate separately
|
||||
child_qs = self._evaluate_node(child, base_queryset, view)
|
||||
if child_qs is not None:
|
||||
logical_querysets.append(child_qs)
|
||||
else:
|
||||
# This is a leaf with field conditions, collect them
|
||||
collected_conditions.update(child)
|
||||
|
||||
# Start with base queryset
|
||||
result_qs = base_queryset
|
||||
|
||||
# Apply collected field conditions together if any exist
|
||||
if collected_conditions:
|
||||
result_qs = self._filter_leaf_via_backend(
|
||||
collected_conditions, result_qs, view
|
||||
)
|
||||
if result_qs is None:
|
||||
return None
|
||||
|
||||
# Intersect with any logical operation results
|
||||
for logical_qs in logical_querysets:
|
||||
result_qs = result_qs & logical_qs
|
||||
|
||||
return result_qs
|
||||
|
||||
def _filter_leaf_via_backend(self, leaf_conditions, base_queryset, view):
|
||||
"""Evaluate a leaf dict by delegating to DjangoFilterBackend once.
|
||||
|
||||
We serialize the leaf dict into a mini querystring and let the view's
|
||||
filterset_class perform validation, conversion, and filtering. This returns
|
||||
a lazy queryset suitable for set-operations with siblings.
|
||||
"""
|
||||
if not leaf_conditions:
|
||||
return None
|
||||
|
||||
# Build a QueryDict from the leaf conditions
|
||||
qd = QueryDict(mutable=True)
|
||||
for key, value in leaf_conditions.items():
|
||||
# Default serialization to string; QueryDict expects strings
|
||||
if isinstance(value, list):
|
||||
# Repeat key for list values (e.g., __in)
|
||||
qd.setlist(key, [str(v) for v in value])
|
||||
else:
|
||||
qd[key] = "" if value is None else str(value)
|
||||
|
||||
qd = qd.copy()
|
||||
qd._mutable = False
|
||||
|
||||
# Temporarily patch request.GET and delegate to DjangoFilterBackend
|
||||
backend = DjangoFilterBackend()
|
||||
request = view.request
|
||||
original_get = request._request.GET if hasattr(request, "_request") else None
|
||||
try:
|
||||
if hasattr(request, "_request"):
|
||||
request._request.GET = qd
|
||||
return backend.filter_queryset(request, base_queryset, view)
|
||||
finally:
|
||||
if hasattr(request, "_request") and original_get is not None:
|
||||
request._request.GET = original_get
|
||||
|
||||
def _get_max_depth(self, view):
|
||||
"""Return the maximum allowed nesting depth for complex filters.
|
||||
|
||||
Falls back to class default if the view does not specify it or has
|
||||
an invalid value.
|
||||
"""
|
||||
value = getattr(view, "complex_filter_max_depth", self.default_max_depth)
|
||||
try:
|
||||
value_int = int(value)
|
||||
if value_int <= 0:
|
||||
return self.default_max_depth
|
||||
return value_int
|
||||
except Exception:
|
||||
return self.default_max_depth
|
||||
|
||||
def _validate_structure(self, node, max_depth, current_depth):
|
||||
"""Validate JSON structure and enforce nesting depth.
|
||||
|
||||
Rules:
|
||||
- Each object may contain only one logical operator:
|
||||
or/and/not (case-insensitive)
|
||||
- Logical operator objects cannot contain field keys alongside the
|
||||
operator
|
||||
- or/and values must be non-empty lists of dicts
|
||||
- not value must be a dict
|
||||
- Leaf objects must only contain field keys and acceptable values
|
||||
- Depth must not exceed max_depth
|
||||
"""
|
||||
if current_depth > max_depth:
|
||||
raise ValidationError(
|
||||
f"Filter nesting is too deep (max {max_depth}); found depth"
|
||||
f" {current_depth}"
|
||||
)
|
||||
|
||||
if not isinstance(node, dict):
|
||||
raise ValidationError("Each filter node must be a JSON object")
|
||||
|
||||
if not node:
|
||||
raise ValidationError("Filter objects must not be empty")
|
||||
|
||||
logical_keys = [
|
||||
k
|
||||
for k in node.keys()
|
||||
if isinstance(k, str) and k.lower() in ("or", "and", "not")
|
||||
]
|
||||
|
||||
if len(logical_keys) > 1:
|
||||
raise ValidationError(
|
||||
"A filter object cannot contain multiple logical operators at"
|
||||
" the same level"
|
||||
)
|
||||
|
||||
if len(logical_keys) == 1:
|
||||
op_key = logical_keys[0]
|
||||
# must not mix operator with other keys
|
||||
if len(node) != 1:
|
||||
raise ValidationError(
|
||||
f"Cannot mix logical operator '{op_key}' with field keys at"
|
||||
f" the same level"
|
||||
)
|
||||
|
||||
op = op_key.lower()
|
||||
value = node[op_key]
|
||||
|
||||
if op in ("or", "and"):
|
||||
if not isinstance(value, list) or len(value) == 0:
|
||||
raise ValidationError(
|
||||
f"'{op}' must be a non-empty list of filter objects"
|
||||
)
|
||||
for child in value:
|
||||
if not isinstance(child, dict):
|
||||
raise ValidationError(
|
||||
f"All children of '{op}' must be JSON objects"
|
||||
)
|
||||
self._validate_structure(
|
||||
child,
|
||||
max_depth=max_depth,
|
||||
current_depth=current_depth + 1,
|
||||
)
|
||||
return
|
||||
|
||||
if op == "not":
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationError("'not' must be a single JSON object")
|
||||
self._validate_structure(
|
||||
value, max_depth=max_depth, current_depth=current_depth + 1
|
||||
)
|
||||
return
|
||||
|
||||
# Leaf node: validate fields and values
|
||||
self._validate_leaf(node)
|
||||
|
||||
def _validate_leaf(self, leaf):
|
||||
"""Validate a leaf dict containing field lookups and values."""
|
||||
if not isinstance(leaf, dict) or not leaf:
|
||||
raise ValidationError("Leaf filter must be a non-empty JSON object")
|
||||
|
||||
for key, value in leaf.items():
|
||||
if isinstance(key, str) and key.lower() in ("or", "and", "not"):
|
||||
raise ValidationError(
|
||||
"Logical operators cannot appear in a leaf filter object"
|
||||
)
|
||||
|
||||
# Lists/Tuples must contain only scalar values
|
||||
if isinstance(value, (list, tuple)):
|
||||
if len(value) == 0:
|
||||
raise ValidationError(f"List value for '{key}' must not be empty")
|
||||
for item in value:
|
||||
if not self._is_scalar(item):
|
||||
raise ValidationError(
|
||||
f"List value for '{key}' must contain only scalar items"
|
||||
)
|
||||
continue
|
||||
|
||||
# Scalars and None are allowed
|
||||
if not self._is_scalar(value):
|
||||
raise ValidationError(
|
||||
f"Value for '{key}' must be a scalar, null, or list/tuple of"
|
||||
f" scalars"
|
||||
)
|
||||
|
||||
def _is_scalar(self, value):
|
||||
return value is None or isinstance(value, (str, int, float, bool))
|
||||
146
apps/api/plane/utils/filters/filter_migrations.py
Normal file
146
apps/api/plane/utils/filters/filter_migrations.py
Normal file
@ -0,0 +1,146 @@
|
||||
"""
|
||||
Utilities for migrating legacy filters to rich filters format.
|
||||
|
||||
This module contains helper functions for data migrations that convert
|
||||
filters fields to rich_filters fields using the LegacyToRichFiltersConverter.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from .converters import LegacyToRichFiltersConverter
|
||||
|
||||
|
||||
logger = logging.getLogger("plane.api.filters.migration")
|
||||
|
||||
|
||||
def migrate_single_model_filters(
|
||||
model_class, model_name: str, converter: LegacyToRichFiltersConverter
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
Migrate filters to rich_filters for a single model.
|
||||
|
||||
Args:
|
||||
model_class: Django model class
|
||||
model_name: Human-readable name for logging
|
||||
converter: Instance of LegacyToRichFiltersConverter
|
||||
|
||||
Returns:
|
||||
Tuple of (updated_count, error_count)
|
||||
"""
|
||||
# Find records that need migration - have filters but empty rich_filters
|
||||
records_to_migrate = model_class.objects.exclude(filters={}).filter(rich_filters={})
|
||||
|
||||
if records_to_migrate.count() == 0:
|
||||
logger.info(f"No {model_name} records need migration")
|
||||
return 0, 0
|
||||
|
||||
logger.info(f"Found {records_to_migrate.count()} {model_name} records to migrate")
|
||||
|
||||
updated_records = []
|
||||
conversion_errors = 0
|
||||
|
||||
for record in records_to_migrate:
|
||||
try:
|
||||
if record.filters: # Double check that filters is not empty
|
||||
rich_filters = converter.convert(record.filters, strict=False)
|
||||
record.rich_filters = rich_filters
|
||||
updated_records.append(record)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to convert filters for {model_name} ID {record.id}: {str(e)}"
|
||||
)
|
||||
conversion_errors += 1
|
||||
continue
|
||||
|
||||
# Bulk update all successfully converted records
|
||||
if updated_records:
|
||||
model_class.objects.bulk_update(
|
||||
updated_records, ["rich_filters"], batch_size=1000
|
||||
)
|
||||
logger.info(f"Successfully updated {len(updated_records)} {model_name} records")
|
||||
|
||||
return len(updated_records), conversion_errors
|
||||
|
||||
|
||||
def migrate_models_filters_to_rich_filters(
|
||||
models_to_migrate: Dict[str, Any],
|
||||
converter: LegacyToRichFiltersConverter,
|
||||
) -> Dict[str, Tuple[int, int]]:
|
||||
"""
|
||||
Migrate legacy filters to rich_filters format for provided models.
|
||||
|
||||
Args:
|
||||
models_to_migrate: Dict mapping model names to model classes
|
||||
|
||||
Returns:
|
||||
Dictionary mapping model names to (updated_count, error_count) tuples
|
||||
"""
|
||||
# Initialize the converter with default settings
|
||||
|
||||
logger.info("Starting filters to rich_filters migration for all models")
|
||||
|
||||
results = {}
|
||||
total_updated = 0
|
||||
total_errors = 0
|
||||
|
||||
for model_name, model_class in models_to_migrate.items():
|
||||
try:
|
||||
updated_count, error_count = migrate_single_model_filters(
|
||||
model_class, model_name, converter
|
||||
)
|
||||
|
||||
results[model_name] = (updated_count, error_count)
|
||||
total_updated += updated_count
|
||||
total_errors += error_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to migrate {model_name}: {str(e)}")
|
||||
results[model_name] = (0, 1)
|
||||
total_errors += 1
|
||||
continue
|
||||
|
||||
# Log final summary
|
||||
logger.info(
|
||||
f"Migration completed for all models. Total updated: {total_updated}, "
|
||||
f"Total errors: {total_errors}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def clear_models_rich_filters(models_to_clear: Dict[str, Any]) -> Dict[str, int]:
|
||||
"""
|
||||
Clear rich_filters field for provided models (for reverse migration).
|
||||
|
||||
Args:
|
||||
models_to_clear: Dictionary mapping model names to model classes
|
||||
|
||||
Returns:
|
||||
Dictionary mapping model names to count of cleared records
|
||||
"""
|
||||
logger.info("Starting reverse migration - clearing rich_filters for all models")
|
||||
|
||||
results = {}
|
||||
total_cleared = 0
|
||||
|
||||
for model_name, model_class in models_to_clear.items():
|
||||
try:
|
||||
# Clear rich_filters for all records that have them
|
||||
updated_count = model_class.objects.exclude(rich_filters={}).update(
|
||||
rich_filters={}
|
||||
)
|
||||
results[model_name] = updated_count
|
||||
total_cleared += updated_count
|
||||
logger.info(
|
||||
f"Cleared rich_filters for {updated_count} {model_name} records"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear rich_filters for {model_name}: {str(e)}")
|
||||
results[model_name] = 0
|
||||
continue
|
||||
|
||||
logger.info(f"Reverse migration completed. Total cleared: {total_cleared}")
|
||||
return results
|
||||
180
apps/api/plane/utils/filters/filterset.py
Normal file
180
apps/api/plane/utils/filters/filterset.py
Normal file
@ -0,0 +1,180 @@
|
||||
import copy
|
||||
|
||||
from django_filters import FilterSet, filters
|
||||
|
||||
from plane.db.models import Issue
|
||||
|
||||
|
||||
class UUIDInFilter(filters.BaseInFilter, filters.UUIDFilter):
|
||||
pass
|
||||
|
||||
|
||||
class CharInFilter(filters.BaseInFilter, filters.CharFilter):
|
||||
pass
|
||||
|
||||
|
||||
class BaseFilterSet(FilterSet):
|
||||
@classmethod
|
||||
def get_filters(cls):
|
||||
"""
|
||||
Get all filters for the filterset, including dynamically created __exact filters.
|
||||
"""
|
||||
# Get the standard filters first
|
||||
filters = super().get_filters()
|
||||
|
||||
# Add __exact versions for filters that have 'exact' lookup
|
||||
exact_filters = {}
|
||||
for filter_name, filter_obj in filters.items():
|
||||
if hasattr(filter_obj, "lookup_expr") and filter_obj.lookup_expr == "exact":
|
||||
exact_field_name = f"{filter_name}__exact"
|
||||
if exact_field_name not in filters:
|
||||
# Copy the filter object as-is and assign it to the new name
|
||||
exact_filters[exact_field_name] = copy.deepcopy(filter_obj)
|
||||
|
||||
# Add the exact filters to the main filters dict
|
||||
filters.update(exact_filters)
|
||||
return filters
|
||||
|
||||
|
||||
class IssueFilterSet(BaseFilterSet):
|
||||
# Custom filter methods to handle soft delete exclusion for relations
|
||||
|
||||
assignee_id = filters.UUIDFilter(method="filter_assignee_id")
|
||||
assignee_id__in = UUIDInFilter(method="filter_assignee_id_in", lookup_expr="in")
|
||||
|
||||
cycle_id = filters.UUIDFilter(method="filter_cycle_id")
|
||||
cycle_id__in = UUIDInFilter(method="filter_cycle_id_in", lookup_expr="in")
|
||||
|
||||
module_id = filters.UUIDFilter(method="filter_module_id")
|
||||
module_id__in = UUIDInFilter(method="filter_module_id_in", lookup_expr="in")
|
||||
|
||||
mention_id = filters.UUIDFilter(method="filter_mention_id")
|
||||
mention_id__in = UUIDInFilter(method="filter_mention_id_in", lookup_expr="in")
|
||||
|
||||
label_id = filters.UUIDFilter(method="filter_label_id")
|
||||
label_id__in = UUIDInFilter(method="filter_label_id_in", lookup_expr="in")
|
||||
|
||||
# Direct field lookups remain the same
|
||||
created_by_id = filters.UUIDFilter(field_name="created_by_id")
|
||||
created_by_id__in = UUIDInFilter(field_name="created_by_id", lookup_expr="in")
|
||||
|
||||
is_archived = filters.BooleanFilter(method="filter_is_archived")
|
||||
|
||||
state_group = filters.CharFilter(field_name="state__group")
|
||||
state_group__in = CharInFilter(field_name="state__group", lookup_expr="in")
|
||||
|
||||
state_id = filters.UUIDFilter(field_name="state_id")
|
||||
state_id__in = UUIDInFilter(field_name="state_id", lookup_expr="in")
|
||||
|
||||
project_id = filters.UUIDFilter(field_name="project_id")
|
||||
project_id__in = UUIDInFilter(field_name="project_id", lookup_expr="in")
|
||||
|
||||
subscriber_id = filters.UUIDFilter(method="filter_subscriber_id")
|
||||
subscriber_id__in = UUIDInFilter(method="filter_subscriber_id_in", lookup_expr="in")
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = {
|
||||
"start_date": ["exact", "range"],
|
||||
"target_date": ["exact", "range"],
|
||||
"created_at": ["exact", "range"],
|
||||
"is_draft": ["exact"],
|
||||
"priority": ["exact", "in"],
|
||||
}
|
||||
|
||||
def filter_is_archived(self, queryset, name, value):
|
||||
"""
|
||||
Convenience filter: archived=true -> archived_at is not null,
|
||||
archived=false -> archived_at is null
|
||||
"""
|
||||
if value in (True, "true", "True", 1, "1"):
|
||||
return queryset.filter(archived_at__isnull=False)
|
||||
if value in (False, "false", "False", 0, "0"):
|
||||
return queryset.filter(archived_at__isnull=True)
|
||||
return queryset
|
||||
|
||||
# Filter methods with soft delete exclusion for relations
|
||||
|
||||
def filter_assignee_id(self, queryset, name, value):
|
||||
"""Filter by assignee ID, excluding soft deleted users"""
|
||||
return queryset.filter(
|
||||
issue_assignee__assignee_id=value,
|
||||
issue_assignee__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_assignee_id_in(self, queryset, name, value):
|
||||
"""Filter by assignee IDs (in), excluding soft deleted users"""
|
||||
return queryset.filter(
|
||||
issue_assignee__assignee_id__in=value,
|
||||
issue_assignee__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_cycle_id(self, queryset, name, value):
|
||||
"""Filter by cycle ID, excluding soft deleted cycles"""
|
||||
return queryset.filter(
|
||||
issue_cycle__cycle_id=value,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_cycle_id_in(self, queryset, name, value):
|
||||
"""Filter by cycle IDs (in), excluding soft deleted cycles"""
|
||||
return queryset.filter(
|
||||
issue_cycle__cycle_id__in=value,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_module_id(self, queryset, name, value):
|
||||
"""Filter by module ID, excluding soft deleted modules"""
|
||||
return queryset.filter(
|
||||
issue_module__module_id=value,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_module_id_in(self, queryset, name, value):
|
||||
"""Filter by module IDs (in), excluding soft deleted modules"""
|
||||
return queryset.filter(
|
||||
issue_module__module_id__in=value,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_mention_id(self, queryset, name, value):
|
||||
"""Filter by mention ID, excluding soft deleted users"""
|
||||
return queryset.filter(
|
||||
issue_mention__mention_id=value,
|
||||
issue_mention__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_mention_id_in(self, queryset, name, value):
|
||||
"""Filter by mention IDs (in), excluding soft deleted users"""
|
||||
return queryset.filter(
|
||||
issue_mention__mention_id__in=value,
|
||||
issue_mention__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_label_id(self, queryset, name, value):
|
||||
"""Filter by label ID, excluding soft deleted labels"""
|
||||
return queryset.filter(
|
||||
label_issue__label_id=value,
|
||||
label_issue__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_label_id_in(self, queryset, name, value):
|
||||
"""Filter by label IDs (in), excluding soft deleted labels"""
|
||||
return queryset.filter(
|
||||
label_issue__label_id__in=value,
|
||||
label_issue__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_subscriber_id(self, queryset, name, value):
|
||||
"""Filter by subscriber ID, excluding soft deleted users"""
|
||||
return queryset.filter(
|
||||
issue_subscribers__subscriber_id=value,
|
||||
issue_subscribers__deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
def filter_subscriber_id_in(self, queryset, name, value):
|
||||
"""Filter by subscriber IDs (in), excluding soft deleted users"""
|
||||
return queryset.filter(
|
||||
issue_subscribers__subscriber_id__in=value,
|
||||
issue_subscribers__deleted_at__isnull=True,
|
||||
)
|
||||
@ -148,6 +148,7 @@ def issue_group_values(
|
||||
slug: str,
|
||||
project_id: Optional[str] = None,
|
||||
filters: Dict[str, Any] = {},
|
||||
queryset: Optional[QuerySet] = None,
|
||||
) -> List[Union[str, Any]]:
|
||||
if field == "state_id":
|
||||
queryset = State.objects.filter(
|
||||
@ -207,36 +208,24 @@ def issue_group_values(
|
||||
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()
|
||||
)
|
||||
queryset = queryset.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()
|
||||
)
|
||||
queryset = queryset.values_list("start_date", flat=True).distinct()
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
|
||||
if field == "created_by":
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug)
|
||||
.filter(**filters)
|
||||
.values_list("created_by", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
queryset = queryset.values_list("created_by", flat=True).distinct()
|
||||
if project_id:
|
||||
return list(queryset.filter(project_id=project_id))
|
||||
else:
|
||||
return list(queryset)
|
||||
|
||||
return []
|
||||
|
||||
@ -14,19 +14,16 @@ import {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
TIssueLayouts,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
|
||||
export const ProfileIssuesMobileHeader = observer(() => {
|
||||
// plane i18n
|
||||
@ -37,14 +34,7 @@ export const ProfileIssuesMobileHeader = observer(() => {
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.PROFILE);
|
||||
|
||||
const { workspaceLabels } = useLabel();
|
||||
// derived values
|
||||
const states = undefined;
|
||||
// const members = undefined;
|
||||
// const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
// const states = undefined;
|
||||
const members = undefined;
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
@ -61,31 +51,6 @@ export const ProfileIssuesMobileHeader = observer(() => {
|
||||
[workspaceSlug, updateFilters, userId]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !userId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
userId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, issueFilters, updateFilters, userId]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !userId) return;
|
||||
@ -145,32 +110,6 @@ export const ProfileIssuesMobileHeader = observer(() => {
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<div className="flex flex-center text-sm text-custom-text-200">
|
||||
{t("common.filters")}
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" strokeWidth={2} />
|
||||
</div>
|
||||
}
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues[activeLayout] : undefined
|
||||
}
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
states={states}
|
||||
labels={workspaceLabels}
|
||||
memberIds={members}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title={t("common.display")}
|
||||
@ -184,7 +123,7 @@ export const ProfileIssuesMobileHeader = observer(() => {
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues[activeLayout] : undefined
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues.layoutOptions[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
|
||||
@ -4,7 +4,7 @@ import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { ChartNoAxesColumn, ListFilter, PanelRight, SlidersHorizontal } from "lucide-react";
|
||||
import { ChartNoAxesColumn, PanelRight, SlidersHorizontal } from "lucide-react";
|
||||
// plane imports
|
||||
import {
|
||||
EIssueFilterType,
|
||||
@ -23,11 +23,10 @@ import {
|
||||
ICustomSearchSelectOption,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import { Breadcrumbs, Button, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui";
|
||||
import { cn, isIssueFilterActive } from "@plane/utils";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
import { SwitcherLabel } from "@/components/common/switcher-label";
|
||||
@ -35,7 +34,6 @@ import { CycleQuickActions } from "@/components/cycles/quick-actions";
|
||||
import {
|
||||
DisplayFiltersSelection,
|
||||
FiltersDropdown,
|
||||
FilterSelection,
|
||||
LayoutSelection,
|
||||
MobileLayoutSelection,
|
||||
} from "@/components/issues/issue-layouts/filters";
|
||||
@ -43,10 +41,7 @@ import {
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
@ -75,11 +70,6 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
const { currentProjectCycleIds, getCycleById } = useCycle();
|
||||
const { toggleCreateIssueModal } = useCommandPalette();
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
@ -100,27 +90,6 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
[workspaceSlug, projectId, cycleId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, cycleId);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
@ -239,27 +208,6 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
activeLayout={activeLayout}
|
||||
/>
|
||||
</div>
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
miniIcon={<ListFilter className="size-3.5" />}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown
|
||||
title={t("common.display")}
|
||||
placement="bottom-end"
|
||||
@ -267,7 +215,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
|
||||
@ -7,48 +7,39 @@ import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
|
||||
export const CycleIssuesMobileHeader = () => {
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
const { getCycleById } = useCycle();
|
||||
const layouts = [
|
||||
const SUPPORTED_LAYOUTS = [
|
||||
{ key: "list", titleTranslationKey: "issue.layouts.list", icon: List },
|
||||
{ key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: Kanban },
|
||||
{ key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: Calendar },
|
||||
];
|
||||
|
||||
export const CycleIssuesMobileHeader = () => {
|
||||
// router
|
||||
const { workspaceSlug, projectId, cycleId } = useParams();
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
|
||||
// states
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { getCycleById } = useCycle();
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
// derived values
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
@ -64,37 +55,6 @@ export const CycleIssuesMobileHeader = () => {
|
||||
[workspaceSlug, projectId, cycleId, updateFilters]
|
||||
);
|
||||
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
cycleId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
@ -142,7 +102,7 @@ export const CycleIssuesMobileHeader = () => {
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{layouts.map((layout, index) => (
|
||||
{SUPPORTED_LAYOUTS.map((layout, index) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={ISSUE_LAYOUTS[index].key}
|
||||
onClick={() => {
|
||||
@ -155,34 +115,6 @@ export const CycleIssuesMobileHeader = () => {
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-custom-text-200 text-sm">
|
||||
{t("common.filters")}
|
||||
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
|
||||
</span>
|
||||
}
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
|
||||
<FiltersDropdown
|
||||
title={t("common.display")}
|
||||
@ -196,7 +128,7 @@ export const CycleIssuesMobileHeader = () => {
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
|
||||
@ -7,28 +7,17 @@ import { ChevronDown } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
import {
|
||||
DisplayFiltersSelection,
|
||||
FilterSelection,
|
||||
FiltersDropdown,
|
||||
MobileLayoutSelection,
|
||||
} from "@/components/issues/issue-layouts/filters";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
|
||||
export const ProjectIssuesMobileHeader = observer(() => {
|
||||
// i18n
|
||||
@ -39,16 +28,11 @@ export const ProjectIssuesMobileHeader = observer(() => {
|
||||
projectId: string;
|
||||
};
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
@ -59,27 +43,6 @@ export const ProjectIssuesMobileHeader = observer(() => {
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues });
|
||||
},
|
||||
[workspaceSlug, projectId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
@ -108,34 +71,6 @@ export const ProjectIssuesMobileHeader = observer(() => {
|
||||
layouts={[EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN, EIssueLayoutTypes.CALENDAR]}
|
||||
onChange={handleLayoutChange}
|
||||
/>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-sm text-custom-text-200">
|
||||
{t("common.filters")}
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
|
||||
</span>
|
||||
}
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title={t("common.display")}
|
||||
@ -149,7 +84,7 @@ export const ProjectIssuesMobileHeader = observer(() => {
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
|
||||
@ -4,7 +4,7 @@ import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { ChartNoAxesColumn, ListFilter, PanelRight, SlidersHorizontal } from "lucide-react";
|
||||
import { ChartNoAxesColumn, PanelRight, SlidersHorizontal } from "lucide-react";
|
||||
// plane imports
|
||||
import {
|
||||
EIssueFilterType,
|
||||
@ -21,18 +21,16 @@ import {
|
||||
ICustomSearchSelectOption,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
import { cn, isIssueFilterActive } from "@plane/utils";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
import { SwitcherLabel } from "@/components/common/switcher-label";
|
||||
import {
|
||||
DisplayFiltersSelection,
|
||||
FiltersDropdown,
|
||||
FilterSelection,
|
||||
LayoutSelection,
|
||||
MobileLayoutSelection,
|
||||
} from "@/components/issues/issue-layouts/filters";
|
||||
@ -41,11 +39,8 @@ import { ModuleQuickActions } from "@/components/modules";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
@ -74,21 +69,22 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
const { toggleCreateIssueModal } = useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const { projectLabels } = useLabel();
|
||||
const { projectStates } = useProjectState();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
// local storage
|
||||
const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false");
|
||||
|
||||
// derived values
|
||||
const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false;
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||
const canUserCreateIssue = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
const workItemsCount = getGroupIssueCount(undefined, undefined, false);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setValue(`${!isSidebarCollapsed}`);
|
||||
};
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
if (!projectId) return;
|
||||
@ -97,27 +93,6 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
[projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues });
|
||||
},
|
||||
[projectId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!projectId) return;
|
||||
@ -134,15 +109,6 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
[projectId, updateFilters]
|
||||
);
|
||||
|
||||
// derived values
|
||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||
const canUserCreateIssue = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
|
||||
const workItemsCount = getGroupIssueCount(undefined, undefined, false);
|
||||
|
||||
const switcherOptions = projectModuleIds
|
||||
?.map((id) => {
|
||||
const _module = id === moduleId ? moduleDetails : getModuleById(id);
|
||||
@ -230,27 +196,6 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
activeLayout={activeLayout}
|
||||
/>
|
||||
</div>
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
miniIcon={<ListFilter className="size-3.5" />}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown
|
||||
title="Display"
|
||||
placement="bottom-end"
|
||||
@ -258,7 +203,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
|
||||
@ -8,53 +8,43 @@ import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
|
||||
export const ModuleIssuesMobileHeader = observer(() => {
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { getModuleById } = useModule();
|
||||
const { t } = useTranslation();
|
||||
const layouts = [
|
||||
const SUPPORTED_LAYOUTS = [
|
||||
{ key: "list", i18n_title: "issue.layouts.list", icon: List },
|
||||
{ key: "kanban", i18n_title: "issue.layouts.kanban", icon: Kanban },
|
||||
{ key: "calendar", i18n_title: "issue.layouts.calendar", icon: Calendar },
|
||||
];
|
||||
|
||||
export const ModuleIssuesMobileHeader = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId, moduleId } = useParams() as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
moduleId: string;
|
||||
};
|
||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||
|
||||
// states
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { getModuleById } = useModule();
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.MODULE);
|
||||
// derived values
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
@ -64,27 +54,6 @@ export const ModuleIssuesMobileHeader = observer(() => {
|
||||
[workspaceSlug, projectId, moduleId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, moduleId);
|
||||
},
|
||||
[workspaceSlug, projectId, moduleId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
@ -118,7 +87,7 @@ export const ModuleIssuesMobileHeader = observer(() => {
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{layouts.map((layout, index) => (
|
||||
{SUPPORTED_LAYOUTS.map((layout, index) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={layout.key}
|
||||
onClick={() => {
|
||||
@ -131,34 +100,6 @@ export const ModuleIssuesMobileHeader = observer(() => {
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-sm text-custom-text-200">
|
||||
Filters
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
|
||||
</span>
|
||||
}
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title="Display"
|
||||
@ -172,7 +113,7 @@ export const ModuleIssuesMobileHeader = observer(() => {
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
|
||||
@ -21,29 +21,19 @@ import {
|
||||
ICustomSearchSelectOption,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
// components
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
|
||||
import {
|
||||
DisplayFiltersSelection,
|
||||
FiltersDropdown,
|
||||
FilterSelection,
|
||||
LayoutSelection,
|
||||
} from "@/components/issues/issue-layouts/filters";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, LayoutSelection } from "@/components/issues/issue-layouts/filters";
|
||||
// constants
|
||||
import { ViewQuickActions } from "@/components/views/quick-actions";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useProjectView } from "@/hooks/store/use-project-view";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web
|
||||
@ -65,11 +55,6 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const { projectViewIds, getViewById } = useProjectView();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
@ -87,33 +72,6 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
[workspaceSlug, projectId, viewId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId || !viewId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
viewId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, viewId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId || !viewId) return;
|
||||
@ -217,33 +175,10 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
disabled={!canUserCreateIssue}
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
projectId={projectId.toString()}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title="Display" placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
|
||||
@ -7,7 +7,6 @@ import { useParams } from "next/navigation";
|
||||
import { DEFAULT_GLOBAL_VIEWS_LIST } from "@plane/constants";
|
||||
// components
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { GlobalViewsAppliedFiltersRoot } from "@/components/issues/issue-layouts/filters";
|
||||
import { AllIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/all-issue-layout-root";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
@ -29,14 +28,7 @@ const GlobalViewIssuesPage = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="h-full overflow-hidden bg-custom-background-100">
|
||||
<div className="flex h-full w-full flex-col border-b border-custom-border-300">
|
||||
{globalViewId && (
|
||||
<GlobalViewsAppliedFiltersRoot globalViewId={globalViewId.toString()} isLoading={isLoading} />
|
||||
)}
|
||||
<AllIssueLayoutRoot isDefaultView={!!defaultView} isLoading={isLoading} toggleLoading={toggleLoading} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@ -16,24 +16,20 @@ import {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
ICustomSearchSelectOption,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { SwitcherLabel } from "@/components/common/switcher-label";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection } from "@/components/issues/issue-layouts/filters";
|
||||
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
import { DefaultWorkspaceViewQuickActions } from "@/components/workspace/views/default-view-quick-action";
|
||||
import { CreateUpdateWorkspaceViewModal } from "@/components/workspace/views/modal";
|
||||
import { WorkspaceViewQuickActions } from "@/components/workspace/views/quick-action";
|
||||
// hooks
|
||||
import { useGlobalView } from "@/hooks/store/use-global-view";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { GlobalViewLayoutSelection } from "@/plane-web/components/views/helper";
|
||||
|
||||
@ -48,10 +44,6 @@ export const GlobalIssuesHeader = observer(() => {
|
||||
issuesFilter: { filters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.GLOBAL);
|
||||
const { getViewDetailsById, currentWorkspaceViews } = useGlobalView();
|
||||
const { workspaceLabels } = useLabel();
|
||||
const {
|
||||
workspace: { workspaceMemberIds },
|
||||
} = useMember();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const issueFilters = globalViewId ? filters[globalViewId.toString()] : undefined;
|
||||
@ -59,33 +51,6 @@ export const GlobalIssuesHeader = observer(() => {
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
const viewDetails = getViewDetailsById(globalViewId.toString());
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !globalViewId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
globalViewId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, issueFilters, updateFilters, globalViewId]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !globalViewId) return;
|
||||
@ -155,7 +120,7 @@ export const GlobalIssuesHeader = observer(() => {
|
||||
) as ICustomSearchSelectOption[];
|
||||
const currentLayoutFilters = useMemo(() => {
|
||||
const layout = activeLayout ?? EIssueLayoutTypes.SPREADSHEET;
|
||||
return ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues[layout];
|
||||
return ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.layoutOptions[layout];
|
||||
}, [activeLayout]);
|
||||
|
||||
return (
|
||||
@ -199,21 +164,6 @@ export const GlobalIssuesHeader = observer(() => {
|
||||
selectedLayout={activeLayout ?? EIssueLayoutTypes.SPREADSHEET}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
layoutDisplayFiltersOptions={currentLayoutFilters}
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
labels={workspaceLabels ?? undefined}
|
||||
memberIds={workspaceMemberIds ?? undefined}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title={t("common.display")} placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={currentLayoutFilters}
|
||||
|
||||
16
apps/web/ce/helpers/work-item-filters/project-level.ts
Normal file
16
apps/web/ce/helpers/work-item-filters/project-level.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { TWorkItemFiltersEntityProps } from "@/plane-web/hooks/work-item-filters/use-work-item-filters-config";
|
||||
|
||||
export type TGetAdditionalPropsForProjectLevelFiltersHOCParams = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TGetAdditionalPropsForProjectLevelFiltersHOC = (
|
||||
params: TGetAdditionalPropsForProjectLevelFiltersHOCParams
|
||||
) => TWorkItemFiltersEntityProps;
|
||||
|
||||
export const getAdditionalProjectLevelFiltersHOCProps: TGetAdditionalPropsForProjectLevelFiltersHOC = ({
|
||||
workspaceSlug,
|
||||
}) => ({
|
||||
workspaceSlug,
|
||||
});
|
||||
@ -0,0 +1,15 @@
|
||||
import { CORE_OPERATORS, TSupportedOperators } from "@plane/types";
|
||||
|
||||
export type TFiltersOperatorConfigs = {
|
||||
allowedOperators: Set<TSupportedOperators>;
|
||||
allowNegative: boolean;
|
||||
};
|
||||
|
||||
export type TUseFiltersOperatorConfigsProps = {
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const useFiltersOperatorConfigs = (_props: TUseFiltersOperatorConfigsProps): TFiltersOperatorConfigs => ({
|
||||
allowedOperators: new Set(Object.values(CORE_OPERATORS)),
|
||||
allowNegative: false,
|
||||
});
|
||||
@ -0,0 +1,366 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
AtSign,
|
||||
Briefcase,
|
||||
CalendarCheck2,
|
||||
CalendarClock,
|
||||
CircleUserRound,
|
||||
SignalHigh,
|
||||
Tag,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
// plane imports
|
||||
import {
|
||||
ContrastIcon,
|
||||
CycleGroupIcon,
|
||||
DiceIcon,
|
||||
DoubleCircleIcon,
|
||||
PriorityIcon,
|
||||
StateGroupIcon,
|
||||
} from "@plane/propel/icons";
|
||||
import {
|
||||
ICycle,
|
||||
IState,
|
||||
IUserLite,
|
||||
TFilterConfig,
|
||||
TFilterValue,
|
||||
IIssueLabel,
|
||||
IModule,
|
||||
IProject,
|
||||
TWorkItemFilterProperty,
|
||||
} from "@plane/types";
|
||||
import { Avatar, Logo } from "@plane/ui";
|
||||
import {
|
||||
getAssigneeFilterConfig,
|
||||
getCreatedByFilterConfig,
|
||||
getCycleFilterConfig,
|
||||
getFileURL,
|
||||
getLabelFilterConfig,
|
||||
getMentionFilterConfig,
|
||||
getModuleFilterConfig,
|
||||
getPriorityFilterConfig,
|
||||
getProjectFilterConfig,
|
||||
getStartDateFilterConfig,
|
||||
getStateFilterConfig,
|
||||
getStateGroupFilterConfig,
|
||||
getSubscriberFilterConfig,
|
||||
getTargetDateFilterConfig,
|
||||
} from "@plane/utils";
|
||||
// store hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
// plane web imports
|
||||
import { useFiltersOperatorConfigs } from "@/plane-web/hooks/rich-filters/use-filters-operator-configs";
|
||||
|
||||
export type TWorkItemFiltersEntityProps = {
|
||||
workspaceSlug: string;
|
||||
cycleIds?: string[];
|
||||
labelIds?: string[];
|
||||
memberIds?: string[];
|
||||
moduleIds?: string[];
|
||||
projectId?: string;
|
||||
projectIds?: string[];
|
||||
stateIds?: string[];
|
||||
};
|
||||
|
||||
export type TUseWorkItemFiltersConfigProps = {
|
||||
allowedFilters: TWorkItemFilterProperty[];
|
||||
} & TWorkItemFiltersEntityProps;
|
||||
|
||||
export type TWorkItemFiltersConfig = {
|
||||
configs: TFilterConfig<TWorkItemFilterProperty, TFilterValue>[];
|
||||
configMap: {
|
||||
[key in TWorkItemFilterProperty]?: TFilterConfig<TWorkItemFilterProperty, TFilterValue>;
|
||||
};
|
||||
isFilterEnabled: (key: TWorkItemFilterProperty) => boolean;
|
||||
};
|
||||
|
||||
export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): TWorkItemFiltersConfig => {
|
||||
const { allowedFilters, cycleIds, labelIds, memberIds, moduleIds, projectId, projectIds, stateIds, workspaceSlug } =
|
||||
props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { getCycleById } = useCycle();
|
||||
const { getLabelById } = useLabel();
|
||||
const { getModuleById } = useModule();
|
||||
const { getStateById } = useProjectState();
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const operatorConfigs = useFiltersOperatorConfigs({ workspaceSlug });
|
||||
const filtersToShow = useMemo(() => new Set(allowedFilters), [allowedFilters]);
|
||||
const project = useMemo(() => getProjectById(projectId), [projectId, getProjectById]);
|
||||
const members: IUserLite[] | undefined = useMemo(
|
||||
() =>
|
||||
memberIds
|
||||
? (memberIds.map((memberId) => getUserDetails(memberId)).filter((member) => member) as IUserLite[])
|
||||
: undefined,
|
||||
[memberIds, getUserDetails]
|
||||
);
|
||||
const workItemStates: IState[] | undefined = useMemo(
|
||||
() =>
|
||||
stateIds ? (stateIds.map((stateId) => getStateById(stateId)).filter((state) => state) as IState[]) : undefined,
|
||||
[stateIds, getStateById]
|
||||
);
|
||||
const workItemLabels: IIssueLabel[] | undefined = useMemo(
|
||||
() =>
|
||||
labelIds
|
||||
? (labelIds.map((labelId) => getLabelById(labelId)).filter((label) => label) as IIssueLabel[])
|
||||
: undefined,
|
||||
[labelIds, getLabelById]
|
||||
);
|
||||
const cycles = useMemo(
|
||||
() => (cycleIds ? (cycleIds.map((cycleId) => getCycleById(cycleId)).filter((cycle) => cycle) as ICycle[]) : []),
|
||||
[cycleIds, getCycleById]
|
||||
);
|
||||
const modules = useMemo(
|
||||
() =>
|
||||
moduleIds ? (moduleIds.map((moduleId) => getModuleById(moduleId)).filter((module) => module) as IModule[]) : [],
|
||||
[moduleIds, getModuleById]
|
||||
);
|
||||
const projects = useMemo(
|
||||
() =>
|
||||
projectIds
|
||||
? (projectIds.map((projectId) => getProjectById(projectId)).filter((project) => project) as IProject[])
|
||||
: [],
|
||||
[projectIds, getProjectById]
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks if a filter is enabled based on the filters to show.
|
||||
* @param key - The filter key.
|
||||
* @param level - The level of the filter.
|
||||
* @returns True if the filter is enabled, false otherwise.
|
||||
*/
|
||||
const isFilterEnabled = useCallback((key: TWorkItemFilterProperty) => filtersToShow.has(key), [filtersToShow]);
|
||||
|
||||
// state group filter config
|
||||
const stateGroupFilterConfig = useMemo(
|
||||
() =>
|
||||
getStateGroupFilterConfig<TWorkItemFilterProperty>("state_group")({
|
||||
isEnabled: isFilterEnabled("state_group"),
|
||||
filterIcon: DoubleCircleIcon,
|
||||
getOptionIcon: (stateGroupKey) => <StateGroupIcon stateGroup={stateGroupKey} />,
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[isFilterEnabled, operatorConfigs]
|
||||
);
|
||||
|
||||
// state filter config
|
||||
const stateFilterConfig = useMemo(
|
||||
() =>
|
||||
getStateFilterConfig<TWorkItemFilterProperty>("state_id")({
|
||||
isEnabled: isFilterEnabled("state_id") && workItemStates !== undefined,
|
||||
filterIcon: DoubleCircleIcon,
|
||||
getOptionIcon: (state) => <StateGroupIcon stateGroup={state.group} color={state.color} />,
|
||||
states: workItemStates ?? [],
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[isFilterEnabled, workItemStates, operatorConfigs]
|
||||
);
|
||||
|
||||
// label filter config
|
||||
const labelFilterConfig = useMemo(
|
||||
() =>
|
||||
getLabelFilterConfig<TWorkItemFilterProperty>("label_id")({
|
||||
isEnabled: isFilterEnabled("label_id") && workItemLabels !== undefined,
|
||||
filterIcon: Tag,
|
||||
labels: workItemLabels ?? [],
|
||||
getOptionIcon: (color) => (
|
||||
<span className="flex flex-shrink-0 size-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
),
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[isFilterEnabled, workItemLabels, operatorConfigs]
|
||||
);
|
||||
|
||||
// cycle filter config
|
||||
const cycleFilterConfig = useMemo(
|
||||
() =>
|
||||
getCycleFilterConfig<TWorkItemFilterProperty>("cycle_id")({
|
||||
isEnabled: isFilterEnabled("cycle_id") && project?.cycle_view === true && cycles !== undefined,
|
||||
filterIcon: ContrastIcon,
|
||||
getOptionIcon: (cycleGroup) => <CycleGroupIcon cycleGroup={cycleGroup} className="h-3.5 w-3.5 flex-shrink-0" />,
|
||||
cycles: cycles ?? [],
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[isFilterEnabled, project?.cycle_view, cycles, operatorConfigs]
|
||||
);
|
||||
|
||||
// module filter config
|
||||
const moduleFilterConfig = useMemo(
|
||||
() =>
|
||||
getModuleFilterConfig<TWorkItemFilterProperty>("module_id")({
|
||||
isEnabled: isFilterEnabled("module_id") && project?.module_view === true && modules !== undefined,
|
||||
filterIcon: DiceIcon,
|
||||
getOptionIcon: () => <DiceIcon className="h-3 w-3 flex-shrink-0" />,
|
||||
modules: modules ?? [],
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[isFilterEnabled, project?.module_view, modules, operatorConfigs]
|
||||
);
|
||||
|
||||
// assignee filter config
|
||||
const assigneeFilterConfig = useMemo(
|
||||
() =>
|
||||
getAssigneeFilterConfig<TWorkItemFilterProperty>("assignee_id")({
|
||||
isEnabled: isFilterEnabled("assignee_id") && members !== undefined,
|
||||
filterIcon: Users,
|
||||
members: members ?? [],
|
||||
getOptionIcon: (memberDetails) => (
|
||||
<Avatar
|
||||
name={memberDetails.display_name}
|
||||
src={getFileURL(memberDetails.avatar_url)}
|
||||
showTooltip={false}
|
||||
size="sm"
|
||||
/>
|
||||
),
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[isFilterEnabled, members, operatorConfigs]
|
||||
);
|
||||
|
||||
// mention filter config
|
||||
const mentionFilterConfig = useMemo(
|
||||
() =>
|
||||
getMentionFilterConfig<TWorkItemFilterProperty>("mention_id")({
|
||||
isEnabled: isFilterEnabled("mention_id") && members !== undefined,
|
||||
filterIcon: AtSign,
|
||||
members: members ?? [],
|
||||
getOptionIcon: (memberDetails) => (
|
||||
<Avatar
|
||||
name={memberDetails.display_name}
|
||||
src={getFileURL(memberDetails.avatar_url)}
|
||||
showTooltip={false}
|
||||
size="sm"
|
||||
/>
|
||||
),
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[isFilterEnabled, members, operatorConfigs]
|
||||
);
|
||||
|
||||
// created by filter config
|
||||
const createdByFilterConfig = useMemo(
|
||||
() =>
|
||||
getCreatedByFilterConfig<TWorkItemFilterProperty>("created_by_id")({
|
||||
isEnabled: isFilterEnabled("created_by_id") && members !== undefined,
|
||||
filterIcon: CircleUserRound,
|
||||
members: members ?? [],
|
||||
getOptionIcon: (memberDetails) => (
|
||||
<Avatar
|
||||
name={memberDetails.display_name}
|
||||
src={getFileURL(memberDetails.avatar_url)}
|
||||
showTooltip={false}
|
||||
size="sm"
|
||||
/>
|
||||
),
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[isFilterEnabled, members, operatorConfigs]
|
||||
);
|
||||
|
||||
// subscriber filter config
|
||||
const subscriberFilterConfig = useMemo(
|
||||
() =>
|
||||
getSubscriberFilterConfig<TWorkItemFilterProperty>("subscriber_id")({
|
||||
isEnabled: isFilterEnabled("subscriber_id") && members !== undefined,
|
||||
filterIcon: Users,
|
||||
members: members ?? [],
|
||||
getOptionIcon: (memberDetails) => (
|
||||
<Avatar
|
||||
name={memberDetails.display_name}
|
||||
src={getFileURL(memberDetails.avatar_url)}
|
||||
showTooltip={false}
|
||||
size="sm"
|
||||
/>
|
||||
),
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[isFilterEnabled, members, operatorConfigs]
|
||||
);
|
||||
|
||||
// priority filter config
|
||||
const priorityFilterConfig = useMemo(
|
||||
() =>
|
||||
getPriorityFilterConfig<TWorkItemFilterProperty>("priority")({
|
||||
isEnabled: isFilterEnabled("priority"),
|
||||
filterIcon: SignalHigh,
|
||||
getOptionIcon: (priority) => <PriorityIcon priority={priority} />,
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[isFilterEnabled, operatorConfigs]
|
||||
);
|
||||
|
||||
// start date filter config
|
||||
const startDateFilterConfig = useMemo(
|
||||
() =>
|
||||
getStartDateFilterConfig<TWorkItemFilterProperty>("start_date")({
|
||||
isEnabled: true,
|
||||
filterIcon: CalendarClock,
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[operatorConfigs]
|
||||
);
|
||||
|
||||
// target date filter config
|
||||
const targetDateFilterConfig = useMemo(
|
||||
() =>
|
||||
getTargetDateFilterConfig<TWorkItemFilterProperty>("target_date")({
|
||||
isEnabled: true,
|
||||
filterIcon: CalendarCheck2,
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[operatorConfigs]
|
||||
);
|
||||
|
||||
// project filter config
|
||||
const projectFilterConfig = useMemo(
|
||||
() =>
|
||||
getProjectFilterConfig<TWorkItemFilterProperty>("project_id")({
|
||||
isEnabled: isFilterEnabled("project_id") && projects !== undefined,
|
||||
filterIcon: Briefcase,
|
||||
projects: projects,
|
||||
getOptionIcon: (project) => <Logo logo={project.logo_props} size={12} />,
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[isFilterEnabled, projects, operatorConfigs]
|
||||
);
|
||||
|
||||
return {
|
||||
configs: [
|
||||
stateFilterConfig,
|
||||
stateGroupFilterConfig,
|
||||
assigneeFilterConfig,
|
||||
priorityFilterConfig,
|
||||
projectFilterConfig,
|
||||
mentionFilterConfig,
|
||||
labelFilterConfig,
|
||||
cycleFilterConfig,
|
||||
moduleFilterConfig,
|
||||
startDateFilterConfig,
|
||||
targetDateFilterConfig,
|
||||
createdByFilterConfig,
|
||||
subscriberFilterConfig,
|
||||
],
|
||||
configMap: {
|
||||
project_id: projectFilterConfig,
|
||||
state_group: stateGroupFilterConfig,
|
||||
state_id: stateFilterConfig,
|
||||
label_id: labelFilterConfig,
|
||||
cycle_id: cycleFilterConfig,
|
||||
module_id: moduleFilterConfig,
|
||||
assignee_id: assigneeFilterConfig,
|
||||
mention_id: mentionFilterConfig,
|
||||
created_by_id: createdByFilterConfig,
|
||||
subscriber_id: subscriberFilterConfig,
|
||||
priority: priorityFilterConfig,
|
||||
start_date: startDateFilterConfig,
|
||||
target_date: targetDateFilterConfig,
|
||||
},
|
||||
isFilterEnabled,
|
||||
};
|
||||
};
|
||||
@ -48,7 +48,7 @@ export const ArchiveTabsList: FC = observer(() => {
|
||||
tab.shouldRender(projectDetails) && (
|
||||
<Link key={tab.key} href={`/${workspaceSlug}/projects/${projectId}/archives/${tab.key}`}>
|
||||
<span
|
||||
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 py-3 px-4 text-sm font-medium outline-none ${
|
||||
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 py-4 px-4 text-sm font-medium outline-none ${
|
||||
pathname.includes(tab.key)
|
||||
? "border-custom-primary-100 text-custom-primary-100"
|
||||
: "border-transparent hover:border-custom-border-200 text-custom-text-300 hover:text-custom-text-400"
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats";
|
||||
// public
|
||||
import emptyMembers from "@/public/empty-state/empty_members.svg";
|
||||
|
||||
export type TAssigneeData = {
|
||||
id: string | undefined;
|
||||
title: string | undefined;
|
||||
avatar_url: string | undefined;
|
||||
completed: number;
|
||||
total: number;
|
||||
}[];
|
||||
|
||||
type TAssigneeStatComponent = {
|
||||
selectedAssigneeIds: string[];
|
||||
handleAssigneeFiltersUpdate: (assigneeId: string | undefined) => void;
|
||||
distribution: TAssigneeData;
|
||||
isEditable?: boolean;
|
||||
};
|
||||
|
||||
export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => {
|
||||
const { distribution, isEditable, selectedAssigneeIds, handleAssigneeFiltersUpdate } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
{distribution && distribution.length > 0 ? (
|
||||
distribution.map((assignee, index) => {
|
||||
if (assignee?.id)
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={assignee?.id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={assignee?.title ?? undefined} src={getFileURL(assignee?.avatar_url ?? "")} />
|
||||
<span>{assignee?.title ?? ""}</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee?.completed ?? 0}
|
||||
total={assignee?.total ?? 0}
|
||||
{...(isEditable && {
|
||||
onClick: () => handleAssigneeFiltersUpdate(assignee.id),
|
||||
selected: assignee.id ? selectedAssigneeIds.includes(assignee.id) : false,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`unassigned-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
|
||||
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
|
||||
</div>
|
||||
<span>{t("no_assignee")}</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee?.completed ?? 0}
|
||||
total={assignee?.total ?? 0}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
||||
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
|
||||
</div>
|
||||
<h6 className="text-base text-custom-text-300">{t("no_assignee")}</h6>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,86 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats";
|
||||
// public
|
||||
import emptyLabel from "@/public/empty-state/empty_label.svg";
|
||||
|
||||
export type TLabelData = {
|
||||
id: string | undefined;
|
||||
title: string | undefined;
|
||||
color: string | undefined;
|
||||
completed: number;
|
||||
total: number;
|
||||
}[];
|
||||
|
||||
type TLabelStatComponent = {
|
||||
selectedLabelIds: string[];
|
||||
handleLabelFiltersUpdate: (labelId: string | undefined) => void;
|
||||
distribution: TLabelData;
|
||||
isEditable?: boolean;
|
||||
};
|
||||
|
||||
export const LabelStatComponent = observer((props: TLabelStatComponent) => {
|
||||
const { distribution, isEditable, selectedLabelIds, handleLabelFiltersUpdate } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
{distribution && distribution.length > 0 ? (
|
||||
distribution.map((label, index) => {
|
||||
if (label.id) {
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={label.id}
|
||||
title={
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "transparent",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-ellipsis truncate">{label.title ?? t("no_labels_yet")}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed}
|
||||
total={label.total}
|
||||
{...(isEditable && {
|
||||
onClick: () => handleLabelFiltersUpdate(label.id),
|
||||
selected: label.id ? selectedLabelIds.includes(label.id) : false,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`no-label-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "transparent",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">{label.title ?? t("no_labels_yet")}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed}
|
||||
total={label.total}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
||||
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
|
||||
</div>
|
||||
<h6 className="text-base text-custom-text-300">{t("no_labels_yet")}</h6>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,45 @@
|
||||
import { TWorkItemFilterCondition } from "@plane/shared-state";
|
||||
import { TFilterConditionNodeForDisplay, TFilterValue, TWorkItemFilterProperty } from "@plane/types";
|
||||
|
||||
export const PROGRESS_STATS = [
|
||||
{
|
||||
key: "stat-states",
|
||||
i18n_title: "common.states",
|
||||
},
|
||||
{
|
||||
key: "stat-assignees",
|
||||
i18n_title: "common.assignees",
|
||||
},
|
||||
{
|
||||
key: "stat-labels",
|
||||
i18n_title: "common.labels",
|
||||
},
|
||||
];
|
||||
|
||||
type TSelectedFilterProgressStatsType = TFilterConditionNodeForDisplay<TWorkItemFilterProperty, TFilterValue>;
|
||||
|
||||
export type TSelectedFilterProgressStats = {
|
||||
assignees: TSelectedFilterProgressStatsType | undefined;
|
||||
labels: TSelectedFilterProgressStatsType | undefined;
|
||||
stateGroups: TSelectedFilterProgressStatsType | undefined;
|
||||
};
|
||||
|
||||
export const createFilterUpdateHandler =
|
||||
<T extends string>(
|
||||
property: TWorkItemFilterProperty,
|
||||
selectedValues: T[],
|
||||
handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void
|
||||
) =>
|
||||
(value: T | undefined) => {
|
||||
const updatedValues = value ? [...selectedValues] : [];
|
||||
|
||||
if (value) {
|
||||
if (updatedValues.includes(value)) {
|
||||
updatedValues.splice(updatedValues.indexOf(value), 1);
|
||||
} else {
|
||||
updatedValues.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
handleFiltersUpdate({ property, operator: "in", value: updatedValues });
|
||||
};
|
||||
@ -0,0 +1,46 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import { TStateGroups } from "@plane/types";
|
||||
// components
|
||||
import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats";
|
||||
|
||||
export type TStateGroupData = {
|
||||
state: string | undefined;
|
||||
completed: number;
|
||||
total: number;
|
||||
}[];
|
||||
|
||||
type TStateGroupStatComponent = {
|
||||
selectedStateGroups: string[];
|
||||
handleStateGroupFiltersUpdate: (stateGroup: string | undefined) => void;
|
||||
distribution: TStateGroupData;
|
||||
totalIssuesCount: number;
|
||||
isEditable?: boolean;
|
||||
};
|
||||
|
||||
export const StateGroupStatComponent = observer((props: TStateGroupStatComponent) => {
|
||||
const { distribution, isEditable, totalIssuesCount, selectedStateGroups, handleStateGroupFiltersUpdate } = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{distribution.map((group, index) => (
|
||||
<SingleProgressStats
|
||||
key={index}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<StateGroupIcon stateGroup={group.state as TStateGroups} />
|
||||
<span className="text-xs capitalize">{group.state}</span>
|
||||
</div>
|
||||
}
|
||||
completed={group.completed}
|
||||
total={totalIssuesCount}
|
||||
{...(isEditable && {
|
||||
onClick: () => group.state && handleStateGroupFiltersUpdate(group.state),
|
||||
selected: group.state ? selectedStateGroups.includes(group.state) : false,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@ -18,7 +18,7 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
|
||||
<div
|
||||
className={`flex w-full items-center justify-between gap-4 rounded-sm p-1 text-xs ${
|
||||
onClick ? "cursor-pointer hover:bg-custom-background-90" : ""
|
||||
} ${selected ? "bg-custom-background-90" : ""}`}
|
||||
} ${selected ? "bg-custom-background-80" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="w-4/6">{title}</div>
|
||||
|
||||
@ -10,7 +10,8 @@ import { Tab } from "@headlessui/react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PriorityIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { EIssuesStoreType, ICycle, IIssueFilterOptions } from "@plane/types";
|
||||
import { TWorkItemFilterCondition } from "@plane/shared-state";
|
||||
import { EIssuesStoreType, ICycle } from "@plane/types";
|
||||
// ui
|
||||
import { Loader, Avatar } from "@plane/ui";
|
||||
import { cn, renderFormattedDate, renderFormattedDateWithoutYear, getFileURL } from "@plane/utils";
|
||||
@ -35,7 +36,7 @@ export type ActiveCycleStatsProps = {
|
||||
projectId: string;
|
||||
cycle: ICycle | null;
|
||||
cycleId?: string | null;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void;
|
||||
handleFiltersUpdate: (conditions: TWorkItemFilterCondition[]) => void;
|
||||
cycleIssueDetails: ActiveCycleIssueDetails;
|
||||
};
|
||||
|
||||
@ -185,7 +186,9 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
issueId: issue.id,
|
||||
isArchived: !!issue.archived_at,
|
||||
});
|
||||
handleFiltersUpdate("priority", ["urgent", "high"], true);
|
||||
handleFiltersUpdate([
|
||||
{ property: "priority", operator: "in", value: ["urgent", "high"] },
|
||||
]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@ -275,7 +278,9 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
total={assignee.total_issues}
|
||||
onClick={() => {
|
||||
if (assignee.assignee_id) {
|
||||
handleFiltersUpdate("assignees", [assignee.assignee_id], true);
|
||||
handleFiltersUpdate([
|
||||
{ property: "assignee_id", operator: "in", value: [assignee.assignee_id] },
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -332,11 +337,15 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
}
|
||||
completed={label.completed_issues}
|
||||
total={label.total_issues}
|
||||
onClick={() => {
|
||||
onClick={
|
||||
label.label_id
|
||||
? () => {
|
||||
if (label.label_id) {
|
||||
handleFiltersUpdate("labels", [label.label_id], true);
|
||||
handleFiltersUpdate([{ property: "label_id", operator: "in", value: [label.label_id] }]);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
@ -2,30 +2,28 @@
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane package imports
|
||||
// plane imports
|
||||
import { PROGRESS_STATE_GROUPS_DETAILS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ICycle, IIssueFilterOptions } from "@plane/types";
|
||||
import { TWorkItemFilterCondition } from "@plane/shared-state";
|
||||
import { ICycle } from "@plane/types";
|
||||
import { LinearProgressIndicator, Loader } from "@plane/ui";
|
||||
// components
|
||||
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
|
||||
// hooks
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
export type ActiveCycleProgressProps = {
|
||||
cycle: ICycle | null;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void;
|
||||
handleFiltersUpdate: (conditions: TWorkItemFilterCondition[]) => void;
|
||||
};
|
||||
|
||||
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = observer((props) => {
|
||||
const { handleFiltersUpdate, cycle } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { groupedProjectStates } = useProjectState();
|
||||
// derived values
|
||||
const progressIndicatorData = PROGRESS_STATE_GROUPS_DETAILS.map((group, index) => ({
|
||||
id: index,
|
||||
@ -68,10 +66,7 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = observer((props
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 text-sm cursor-pointer"
|
||||
onClick={() => {
|
||||
if (groupedProjectStates) {
|
||||
const states = groupedProjectStates[group].map((state) => state.id);
|
||||
handleFiltersUpdate("state", states, true);
|
||||
}
|
||||
handleFiltersUpdate([{ property: "state_group", operator: "in", value: [group] }]);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import { useCallback } from "react";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
|
||||
// plane imports
|
||||
import { TWorkItemFilterCondition } from "@plane/shared-state";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
// constants
|
||||
import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
|
||||
|
||||
interface IActiveCycleDetails {
|
||||
workspaceSlug: string;
|
||||
@ -21,9 +24,10 @@ const useCyclesDetails = (props: IActiveCycleDetails) => {
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
issuesFilter: { updateFilterExpression },
|
||||
issues: { getActiveCycleById: getActiveCycleByIdFromIssue, fetchActiveCycleIssues },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { updateFilterExpressionFromConditions } = useWorkItemFilters();
|
||||
|
||||
const { fetchActiveCycleProgress, getCycleById, fetchActiveCycleAnalytics } = useCycle();
|
||||
// derived values
|
||||
@ -62,29 +66,19 @@ const useCyclesDetails = (props: IActiveCycleDetails) => {
|
||||
const cycleIssueDetails = cycle?.id ? getActiveCycleByIdFromIssue(cycle?.id) : { nextPageResults: false };
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => {
|
||||
async (conditions: TWorkItemFilterCondition[]) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(issueFilters?.filters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = [];
|
||||
});
|
||||
|
||||
let newValues: string[] = [];
|
||||
|
||||
if (isEqual(newValues, value)) newValues = [];
|
||||
else newValues = value;
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{ ...newFilters, [key]: newValues },
|
||||
cycleId.toString()
|
||||
await updateFilterExpressionFromConditions(
|
||||
EIssuesStoreType.CYCLE,
|
||||
cycleId,
|
||||
conditions,
|
||||
updateFilterExpression.bind(updateFilterExpression, workspaceSlug, projectId, cycleId)
|
||||
);
|
||||
if (redirect) router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`);
|
||||
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters, router]
|
||||
[workspaceSlug, projectId, cycleId, updateFilterExpressionFromConditions, updateFilterExpression, router]
|
||||
);
|
||||
return {
|
||||
cycle,
|
||||
|
||||
@ -1,21 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useCallback, useMemo } from "react";
|
||||
import { FC, useMemo } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ChevronUp, ChevronDown } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EIssuesStoreType, ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types";
|
||||
import { EIssuesStoreType, ICycle, TCyclePlotType, TProgressSnapshot } from "@plane/types";
|
||||
import { getDate } from "@plane/utils";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
// plane web components
|
||||
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
|
||||
import { SidebarChartRoot } from "@/plane-web/components/cycles";
|
||||
// local imports
|
||||
import { CycleProgressStats } from "./progress-stats";
|
||||
@ -60,23 +58,23 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
|
||||
// router
|
||||
const searchParams = useSearchParams();
|
||||
const peekCycle = searchParams.get("peekCycle") || undefined;
|
||||
const { getPlotTypeByCycleId, getEstimateTypeByCycleId, getCycleById } = useCycle();
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
// store hooks
|
||||
const { getPlotTypeByCycleId, getEstimateTypeByCycleId, getCycleById } = useCycle();
|
||||
const { getFilter, updateFilterValueFromSidebar } = useWorkItemFilters();
|
||||
// derived values
|
||||
const cycleFilter = getFilter(EIssuesStoreType.CYCLE, cycleId);
|
||||
const selectedAssignees = cycleFilter?.findFirstConditionByPropertyAndOperator("assignee_id", "in");
|
||||
const selectedLabels = cycleFilter?.findFirstConditionByPropertyAndOperator("label_id", "in");
|
||||
const selectedStateGroups = cycleFilter?.findFirstConditionByPropertyAndOperator("state_group", "in");
|
||||
const cycleDetails = validateCycleSnapshot(getCycleById(cycleId));
|
||||
const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId);
|
||||
const estimateType = getEstimateTypeByCycleId(cycleId);
|
||||
|
||||
const totalIssues = cycleDetails?.total_issues || 0;
|
||||
const totalEstimatePoints = cycleDetails?.total_estimate_points || 0;
|
||||
|
||||
const chartDistributionData =
|
||||
estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined;
|
||||
|
||||
const groupedIssues = useMemo(
|
||||
() => ({
|
||||
backlog:
|
||||
@ -92,44 +90,12 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
|
||||
}),
|
||||
[estimateType, cycleDetails]
|
||||
);
|
||||
|
||||
const cycleStartDate = getDate(cycleDetails?.start_date);
|
||||
const cycleEndDate = getDate(cycleDetails?.end_date);
|
||||
const isCycleStartDateValid = cycleStartDate && cycleStartDate <= new Date();
|
||||
const isCycleEndDateValid = cycleStartDate && cycleEndDate && cycleEndDate >= cycleStartDate;
|
||||
const isCycleDateValid = isCycleStartDateValid && isCycleEndDateValid;
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
let newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (key === "state") {
|
||||
if (isEqual(newValues, value)) newValues = [];
|
||||
else newValues = value;
|
||||
} else {
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
cycleId
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
if (!cycleDetails) return <></>;
|
||||
return (
|
||||
<div className="border-t border-custom-border-200 space-y-4 py-5">
|
||||
@ -159,7 +125,6 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Transition show={open}>
|
||||
<Disclosure.Panel className="flex flex-col divide-y divide-custom-border-200">
|
||||
{cycleStartDate && cycleEndDate ? (
|
||||
@ -172,16 +137,24 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
|
||||
<div className="w-full py-4">
|
||||
<CycleProgressStats
|
||||
cycleId={cycleId}
|
||||
plotType={plotType}
|
||||
distribution={chartDistributionData}
|
||||
groupedIssues={groupedIssues}
|
||||
totalIssuesCount={estimateType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
|
||||
isEditable={Boolean(!peekCycle)}
|
||||
size="xs"
|
||||
roundedTab={false}
|
||||
handleFiltersUpdate={updateFilterValueFromSidebar.bind(
|
||||
updateFilterValueFromSidebar,
|
||||
EIssuesStoreType.CYCLE,
|
||||
cycleId
|
||||
)}
|
||||
isEditable={Boolean(!peekCycle) && cycleFilter !== undefined}
|
||||
noBackground={false}
|
||||
filters={issueFilters}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
plotType={plotType}
|
||||
roundedTab={false}
|
||||
selectedFilters={{
|
||||
assignees: selectedAssignees,
|
||||
labels: selectedLabels,
|
||||
stateGroups: selectedStateGroups,
|
||||
}}
|
||||
size="xs"
|
||||
totalIssuesCount={estimateType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -2,280 +2,67 @@
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import {
|
||||
IIssueFilterOptions,
|
||||
IIssueFilters,
|
||||
TCycleDistribution,
|
||||
TCycleEstimateDistribution,
|
||||
TCyclePlotType,
|
||||
TStateGroups,
|
||||
} from "@plane/types";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { cn, getFileURL } from "@plane/utils";
|
||||
import { TWorkItemFilterCondition } from "@plane/shared-state";
|
||||
import { TCycleDistribution, TCycleEstimateDistribution, TCyclePlotType } from "@plane/types";
|
||||
import { cn, toFilterArray } from "@plane/utils";
|
||||
// components
|
||||
import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats";
|
||||
import { AssigneeStatComponent, TAssigneeData } from "@/components/core/sidebar/progress-stats/assignee";
|
||||
import { LabelStatComponent, TLabelData } from "@/components/core/sidebar/progress-stats/label";
|
||||
import {
|
||||
createFilterUpdateHandler,
|
||||
PROGRESS_STATS,
|
||||
TSelectedFilterProgressStats,
|
||||
} from "@/components/core/sidebar/progress-stats/shared";
|
||||
import { StateGroupStatComponent, TStateGroupData } from "@/components/core/sidebar/progress-stats/state_group";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// public
|
||||
import emptyLabel from "@/public/empty-state/empty_label.svg";
|
||||
import emptyMembers from "@/public/empty-state/empty_members.svg";
|
||||
|
||||
// assignee types
|
||||
type TAssigneeData = {
|
||||
id: string | undefined;
|
||||
title: string | undefined;
|
||||
avatar_url: string | undefined;
|
||||
completed: number;
|
||||
total: number;
|
||||
}[];
|
||||
|
||||
type TAssigneeStatComponent = {
|
||||
distribution: TAssigneeData;
|
||||
isEditable?: boolean;
|
||||
filters?: IIssueFilters | undefined;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
};
|
||||
|
||||
// labelTypes
|
||||
type TLabelData = {
|
||||
id: string | undefined;
|
||||
title: string | undefined;
|
||||
color: string | undefined;
|
||||
completed: number;
|
||||
total: number;
|
||||
}[];
|
||||
|
||||
type TLabelStatComponent = {
|
||||
distribution: TLabelData;
|
||||
isEditable?: boolean;
|
||||
filters?: IIssueFilters | undefined;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
};
|
||||
|
||||
// stateTypes
|
||||
type TStateData = {
|
||||
state: string | undefined;
|
||||
completed: number;
|
||||
total: number;
|
||||
}[];
|
||||
|
||||
type TStateStatComponent = {
|
||||
distribution: TStateData;
|
||||
totalIssuesCount: number;
|
||||
isEditable?: boolean;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
};
|
||||
|
||||
export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => {
|
||||
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
{distribution && distribution.length > 0 ? (
|
||||
distribution.map((assignee, index) => {
|
||||
if (assignee?.id)
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={assignee?.id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={assignee?.title ?? undefined} src={getFileURL(assignee?.avatar_url ?? "")} />
|
||||
<span>{assignee?.title ?? ""}</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee?.completed ?? 0}
|
||||
total={assignee?.total ?? 0}
|
||||
{...(isEditable && {
|
||||
onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""),
|
||||
selected: filters?.filters?.assignees?.includes(assignee.id ?? ""),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`unassigned-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
|
||||
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
|
||||
</div>
|
||||
<span>{t("no_assignee")}</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee?.completed ?? 0}
|
||||
total={assignee?.total ?? 0}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
||||
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
|
||||
</div>
|
||||
<h6 className="text-base text-custom-text-300">{t("no_assignee")}</h6>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const LabelStatComponent = observer((props: TLabelStatComponent) => {
|
||||
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
{distribution && distribution.length > 0 ? (
|
||||
distribution.map((label, index) => {
|
||||
if (label.id) {
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={label.id}
|
||||
title={
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "transparent",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-ellipsis truncate">{label.title ?? t("no_labels_yet")}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed}
|
||||
total={label.total}
|
||||
{...(isEditable && {
|
||||
onClick: () => handleFiltersUpdate("labels", label.id ?? ""),
|
||||
selected: filters?.filters?.labels?.includes(label.id ?? `no-label-${index}`),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`no-label-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "transparent",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">{label.title ?? t("no_labels_yet")}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed}
|
||||
total={label.total}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
||||
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
|
||||
</div>
|
||||
<h6 className="text-base text-custom-text-300">{t("no_labels_yet")}</h6>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const StateStatComponent = observer((props: TStateStatComponent) => {
|
||||
const { distribution, isEditable, totalIssuesCount, handleFiltersUpdate } = props;
|
||||
// hooks
|
||||
const { groupedProjectStates } = useProjectState();
|
||||
// derived values
|
||||
const getStateGroupState = (stateGroup: string) => {
|
||||
const stateGroupStates = groupedProjectStates?.[stateGroup];
|
||||
const stateGroupStatesId = stateGroupStates?.map((state) => state.id);
|
||||
return stateGroupStatesId;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{distribution.map((group, index) => (
|
||||
<SingleProgressStats
|
||||
key={index}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<StateGroupIcon stateGroup={group.state as TStateGroups} />
|
||||
<span className="text-xs capitalize">{group.state}</span>
|
||||
</div>
|
||||
}
|
||||
completed={group.completed}
|
||||
total={totalIssuesCount}
|
||||
{...(isEditable && {
|
||||
onClick: () => group.state && handleFiltersUpdate("state", getStateGroupState(group.state) ?? []),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const progressStats = [
|
||||
{
|
||||
key: "stat-states",
|
||||
i18n_title: "common.states",
|
||||
},
|
||||
{
|
||||
key: "stat-assignees",
|
||||
i18n_title: "common.assignees",
|
||||
},
|
||||
{
|
||||
key: "stat-labels",
|
||||
i18n_title: "common.labels",
|
||||
},
|
||||
];
|
||||
|
||||
type TCycleProgressStats = {
|
||||
cycleId: string;
|
||||
plotType: TCyclePlotType;
|
||||
distribution: TCycleDistribution | TCycleEstimateDistribution | undefined;
|
||||
groupedIssues: Record<string, number>;
|
||||
totalIssuesCount: number;
|
||||
handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void;
|
||||
isEditable?: boolean;
|
||||
filters?: IIssueFilters | undefined;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
size?: "xs" | "sm";
|
||||
roundedTab?: boolean;
|
||||
noBackground?: boolean;
|
||||
plotType: TCyclePlotType;
|
||||
roundedTab?: boolean;
|
||||
selectedFilters: TSelectedFilterProgressStats;
|
||||
size?: "xs" | "sm";
|
||||
totalIssuesCount: number;
|
||||
};
|
||||
|
||||
export const CycleProgressStats: FC<TCycleProgressStats> = observer((props) => {
|
||||
const {
|
||||
cycleId,
|
||||
plotType,
|
||||
distribution,
|
||||
groupedIssues,
|
||||
totalIssuesCount,
|
||||
isEditable = false,
|
||||
filters,
|
||||
handleFiltersUpdate,
|
||||
size = "sm",
|
||||
roundedTab = false,
|
||||
isEditable = false,
|
||||
noBackground = false,
|
||||
plotType,
|
||||
roundedTab = false,
|
||||
selectedFilters,
|
||||
size = "sm",
|
||||
totalIssuesCount,
|
||||
} = props;
|
||||
// hooks
|
||||
// plane imports
|
||||
const { t } = useTranslation();
|
||||
// store imports
|
||||
const { storedValue: currentTab, setValue: setCycleTab } = useLocalStorage(
|
||||
`cycle-analytics-tab-${cycleId}`,
|
||||
"stat-assignees"
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const currentTabIndex = (tab: string): number => progressStats.findIndex((stat) => stat.key === tab);
|
||||
|
||||
const currentTabIndex = (tab: string): number => PROGRESS_STATS.findIndex((stat) => stat.key === tab);
|
||||
const currentDistribution = distribution as TCycleDistribution;
|
||||
const currentEstimateDistribution = distribution as TCycleEstimateDistribution;
|
||||
const selectedAssigneeIds = toFilterArray(selectedFilters?.assignees?.value || []) as string[];
|
||||
const selectedLabelIds = toFilterArray(selectedFilters?.labels?.value || []) as string[];
|
||||
const selectedStateGroups = toFilterArray(selectedFilters?.stateGroups?.value || []) as string[];
|
||||
|
||||
const distributionAssigneeData: TAssigneeData =
|
||||
plotType === "burndown"
|
||||
@ -311,12 +98,24 @@ export const CycleProgressStats: FC<TCycleProgressStats> = observer((props) => {
|
||||
total: label.total_estimates,
|
||||
}));
|
||||
|
||||
const distributionStateData: TStateData = Object.keys(groupedIssues || {}).map((state) => ({
|
||||
const distributionStateData: TStateGroupData = Object.keys(groupedIssues || {}).map((state) => ({
|
||||
state: state,
|
||||
completed: groupedIssues?.[state] || 0,
|
||||
total: totalIssuesCount || 0,
|
||||
}));
|
||||
|
||||
const handleAssigneeFiltersUpdate = createFilterUpdateHandler(
|
||||
"assignee_id",
|
||||
selectedAssigneeIds,
|
||||
handleFiltersUpdate
|
||||
);
|
||||
const handleLabelFiltersUpdate = createFilterUpdateHandler("label_id", selectedLabelIds, handleFiltersUpdate);
|
||||
const handleStateGroupFiltersUpdate = createFilterUpdateHandler(
|
||||
"state_group",
|
||||
selectedStateGroups,
|
||||
handleFiltersUpdate
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tab.Group defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
|
||||
@ -329,7 +128,7 @@ export const CycleProgressStats: FC<TCycleProgressStats> = observer((props) => {
|
||||
size === "xs" ? `text-xs` : `text-sm`
|
||||
)}
|
||||
>
|
||||
{progressStats.map((stat) => (
|
||||
{PROGRESS_STATS.map((stat) => (
|
||||
<Tab
|
||||
className={cn(
|
||||
`p-1 w-full text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all`,
|
||||
@ -347,27 +146,28 @@ export const CycleProgressStats: FC<TCycleProgressStats> = observer((props) => {
|
||||
</Tab.List>
|
||||
<Tab.Panels className="py-3 text-custom-text-200">
|
||||
<Tab.Panel key={"stat-states"}>
|
||||
<StateStatComponent
|
||||
<StateGroupStatComponent
|
||||
distribution={distributionStateData}
|
||||
totalIssuesCount={totalIssuesCount}
|
||||
handleStateGroupFiltersUpdate={handleStateGroupFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
selectedStateGroups={selectedStateGroups}
|
||||
totalIssuesCount={totalIssuesCount}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel key={"stat-assignees"}>
|
||||
<AssigneeStatComponent
|
||||
distribution={distributionAssigneeData}
|
||||
handleAssigneeFiltersUpdate={handleAssigneeFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
filters={filters}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
selectedAssigneeIds={selectedAssigneeIds}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel key={"stat-labels"}>
|
||||
<LabelStatComponent
|
||||
distribution={distributionLabelData}
|
||||
handleLabelFiltersUpdate={handleLabelFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
filters={filters}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
selectedLabelIds={selectedLabelIds}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
|
||||
@ -1,28 +1,17 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane constants
|
||||
// plane imports
|
||||
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
} from "@plane/types";
|
||||
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
import { EHeaderVariant, Header } from "@plane/ui";
|
||||
// components
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
import { ArchiveTabsList } from "@/components/archives";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
// helpers
|
||||
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
|
||||
export const ArchivedIssuesHeader: FC = observer(() => {
|
||||
// router
|
||||
@ -32,34 +21,10 @@ export const ArchivedIssuesHeader: FC = observer(() => {
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// for archived issues list layout is the only option
|
||||
const activeLayout = "list";
|
||||
// hooks
|
||||
const handleFiltersUpdate = (key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
|
||||
[key]: newValues,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisplayFiltersUpdate = (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
@ -77,42 +42,25 @@ export const ArchivedIssuesHeader: FC = observer(() => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative flex border-b border-custom-border-200">
|
||||
<div className="flex w-full items-center overflow-x-auto px-4 gap-2 horizontal-scrollbar scrollbar-sm">
|
||||
<Header variant={EHeaderVariant.SECONDARY}>
|
||||
<Header.LeftItem>
|
||||
<ArchiveTabsList />
|
||||
</div>
|
||||
{/* filter options */}
|
||||
<div className="flex items-center gap-2 px-8">
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters || {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.archived_issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem className="items-center">
|
||||
<FiltersDropdown title={t("common.display")} placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
displayFilters={issueFilters?.displayFilters || {}}
|
||||
displayProperties={issueFilters?.displayProperties || {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
|
||||
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
|
||||
layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.archived_issues.layoutOptions[activeLayout] : undefined
|
||||
}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
||||
@ -2,35 +2,21 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ChartNoAxesColumn, ListFilter, SlidersHorizontal } from "lucide-react";
|
||||
// plane constants
|
||||
import { ChartNoAxesColumn, SlidersHorizontal } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIssueFilterType, ISSUE_STORE_TO_FILTERS_MAP } from "@plane/constants";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import { EIssueLayoutTypes, EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
import { Button } from "@plane/ui";
|
||||
// components
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
// plane web types
|
||||
// plane web imports
|
||||
import { TProject } from "@/plane-web/types";
|
||||
// local imports
|
||||
import { WorkItemsModal } from "../analytics/work-items/modal";
|
||||
import {
|
||||
DisplayFiltersSelection,
|
||||
FiltersDropdown,
|
||||
FilterSelection,
|
||||
LayoutSelection,
|
||||
MobileLayoutSelection,
|
||||
} from "./issue-layouts/filters";
|
||||
@ -63,38 +49,13 @@ export const HeaderFilters = observer((props: Props) => {
|
||||
// states
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
// store hooks
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(storeType);
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
// derived values
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
const layoutDisplayFiltersOptions = ISSUE_STORE_TO_FILTERS_MAP[storeType]?.[activeLayout];
|
||||
const layoutDisplayFiltersOptions = ISSUE_STORE_TO_FILTERS_MAP[storeType]?.layoutOptions[activeLayout];
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues });
|
||||
},
|
||||
[workspaceSlug, projectId, issueFilters, updateFilters]
|
||||
);
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
@ -141,27 +102,6 @@ export const HeaderFilters = observer((props: Props) => {
|
||||
activeLayout={activeLayout}
|
||||
/>
|
||||
</div>
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
miniIcon={<ListFilter className="size-3.5" />}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
projectId={projectId}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
isEpic={storeType === EIssuesStoreType.EPIC}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown
|
||||
miniIcon={<SlidersHorizontal className="size-3.5" />}
|
||||
title={t("common.display")}
|
||||
|
||||
@ -13,7 +13,6 @@ import {
|
||||
FiltersDropdown,
|
||||
} from "@/components/issues/issue-layouts/filters";
|
||||
import { isDisplayFiltersApplied } from "@/components/issues/issue-layouts/utils";
|
||||
|
||||
type TSubIssueDisplayFiltersProps = {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
displayFilters: IIssueDisplayFilterOptions;
|
||||
|
||||
@ -2,7 +2,7 @@ import { FC, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ListFilter, Search, X } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IIssueFilterOptions, ILayoutDisplayFiltersOptions, IState } from "@plane/types";
|
||||
import { IIssueFilterOptions, IState } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
import {
|
||||
FilterAssignees,
|
||||
@ -16,26 +16,24 @@ import {
|
||||
} from "@/components/issues/issue-layouts/filters";
|
||||
import { isFiltersApplied } from "@/components/issues/issue-layouts/utils";
|
||||
import { FilterIssueTypes } from "@/plane-web/components/issues/filters/issue-types";
|
||||
|
||||
type TSubIssueFiltersProps = {
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
filters: IIssueFilterOptions;
|
||||
memberIds: string[] | undefined;
|
||||
states?: IState[];
|
||||
layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined;
|
||||
availableFilters: (keyof IIssueFilterOptions)[];
|
||||
};
|
||||
|
||||
export const SubIssueFilters: FC<TSubIssueFiltersProps> = observer((props) => {
|
||||
const { handleFiltersUpdate, filters, memberIds, states, layoutDisplayFiltersOptions } = props;
|
||||
|
||||
const { handleFiltersUpdate, filters, memberIds, states, availableFilters } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||
|
||||
const isFilterEnabled = (filter: keyof IIssueFilterOptions) =>
|
||||
!!layoutDisplayFiltersOptions?.filters.includes(filter);
|
||||
const isFilterEnabled = (filter: keyof IIssueFilterOptions) => !!availableFilters.includes(filter);
|
||||
|
||||
const isFilterApplied = useMemo(() => isFiltersApplied(filters), [filters]);
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -3,3 +3,4 @@ export * from "./title";
|
||||
export * from "./root";
|
||||
export * from "./quick-action-button";
|
||||
export * from "./display-filters";
|
||||
export * from "./content";
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { FC, useCallback } from "react";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import { observer } from "mobx-react";
|
||||
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import {
|
||||
EIssueFilterType,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE,
|
||||
} from "@plane/constants";
|
||||
import {
|
||||
EIssueServiceType,
|
||||
IIssueDisplayFilterOptions,
|
||||
@ -38,11 +42,10 @@ export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observ
|
||||
} = useMember();
|
||||
|
||||
// derived values
|
||||
const subIssueFilters = getSubIssueFilters(parentId);
|
||||
const projectStates = getProjectStates(projectId);
|
||||
const projectMemberIds = getProjectMemberIds(projectId, false);
|
||||
|
||||
const layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].list;
|
||||
const subIssueFilters = getSubIssueFilters(parentId);
|
||||
const layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].layoutOptions.list;
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
@ -72,7 +75,6 @@ export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observ
|
||||
if (subIssueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateSubWorkItemFilters(EIssueFilterType.FILTERS, { [key]: newValues }, parentId);
|
||||
},
|
||||
[subIssueFilters?.filters, updateSubWorkItemFilters, parentId]
|
||||
@ -100,7 +102,7 @@ export const SubWorkItemTitleActions: FC<TSubWorkItemTitleActionsProps> = observ
|
||||
filters={subIssueFilters?.filters ?? {}}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
|
||||
availableFilters={SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE}
|
||||
/>
|
||||
{!disabled && (
|
||||
<SubIssuesActionButton issueId={parentId} disabled={disabled} issueServiceType={issueServiceType} />
|
||||
|
||||
@ -5,20 +5,17 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { observer } from "mobx-react";
|
||||
// plane constants
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import { TSupportedFilterTypeForUpdate } from "@plane/constants";
|
||||
// types
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
TGroupedIssues,
|
||||
TIssue,
|
||||
TIssueKanbanFilters,
|
||||
TIssueMap,
|
||||
TPaginationData,
|
||||
ICalendarWeek,
|
||||
EIssueLayoutTypes,
|
||||
TSupportedFilterForUpdate,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Spinner } from "@plane/ui";
|
||||
@ -71,8 +68,8 @@ type Props = {
|
||||
readOnly?: boolean;
|
||||
updateFilters?: (
|
||||
projectId: string,
|
||||
filterType: EIssueFilterType,
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters
|
||||
filterType: TSupportedFilterTypeForUpdate,
|
||||
filters: TSupportedFilterForUpdate
|
||||
) => Promise<void>;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
isEpic?: boolean;
|
||||
|
||||
@ -9,15 +9,9 @@ import { Popover, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
// ui
|
||||
// icons
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import { EIssueFilterType, TSupportedFilterTypeForUpdate } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
TCalendarLayouts,
|
||||
TIssueKanbanFilters,
|
||||
} from "@plane/types";
|
||||
import { TCalendarLayouts, TSupportedFilterForUpdate } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
// types
|
||||
// constants
|
||||
@ -39,8 +33,8 @@ interface ICalendarHeader {
|
||||
| IProjectEpicsFilter;
|
||||
updateFilters?: (
|
||||
projectId: string,
|
||||
filterType: EIssueFilterType,
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters
|
||||
filterType: TSupportedFilterTypeForUpdate,
|
||||
filters: TSupportedFilterForUpdate
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@ -2,14 +2,9 @@ import { observer } from "mobx-react";
|
||||
|
||||
// components
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import { TSupportedFilterTypeForUpdate } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
TIssueKanbanFilters,
|
||||
} from "@plane/types";
|
||||
import { TSupportedFilterForUpdate } from "@plane/types";
|
||||
import { Row } from "@plane/ui";
|
||||
// icons
|
||||
import { useCalendarView } from "@/hooks/store/use-calendar-view";
|
||||
@ -29,8 +24,8 @@ interface ICalendarHeader {
|
||||
| IProjectEpicsFilter;
|
||||
updateFilters?: (
|
||||
projectId: string,
|
||||
filterType: EIssueFilterType,
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters
|
||||
filterType: TSupportedFilterTypeForUpdate,
|
||||
filters: TSupportedFilterForUpdate
|
||||
) => Promise<void>;
|
||||
setSelectedDate: (date: Date) => void;
|
||||
}
|
||||
|
||||
@ -1,36 +1,35 @@
|
||||
import size from "lodash/size";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EIssueFilterType, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EIssuesStoreType, EUserProjectRoles, IIssueFilterOptions } from "@plane/types";
|
||||
import { EIssuesStoreType, EUserProjectRoles } from "@plane/types";
|
||||
// components
|
||||
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useWorkItemFilterInstance } from "@/hooks/store/work-item-filters/use-work-item-filter-instance";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
export const ProjectArchivedEmptyState: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
|
||||
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const userFilters = issuesFilter?.issueFilters?.filters;
|
||||
const archivedWorkItemFilter = projectId
|
||||
? useWorkItemFilterInstance(EIssuesStoreType.ARCHIVED, projectId)
|
||||
: undefined;
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
|
||||
const issueFilterCount = size(
|
||||
Object.fromEntries(
|
||||
Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0)
|
||||
)
|
||||
);
|
||||
const additionalPath = issueFilterCount > 0 ? (activeLayout ?? "list") : undefined;
|
||||
const additionalPath = archivedWorkItemFilter?.hasActiveFilters ? (activeLayout ?? "list") : undefined;
|
||||
const canPerformEmptyStateActions = allowPermissions(
|
||||
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
@ -43,27 +42,16 @@ export const ProjectArchivedEmptyState: React.FC = observer(() => {
|
||||
basePath: "/empty-state/archived/empty-issues",
|
||||
});
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = [];
|
||||
});
|
||||
issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
|
||||
...newFilters,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
{issueFilterCount > 0 ? (
|
||||
{archivedWorkItemFilter?.hasActiveFilters ? (
|
||||
<DetailedEmptyState
|
||||
title={t("project_issues.empty_state.issues_empty_filter.title")}
|
||||
assetPath={emptyFilterResolvedPath}
|
||||
secondaryButton={{
|
||||
text: t("project_issues.empty_state.issues_empty_filter.secondary_button.text"),
|
||||
onClick: handleClearAllFilters,
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
onClick: archivedWorkItemFilter?.clearFilters,
|
||||
disabled: !canPerformEmptyStateActions || !archivedWorkItemFilter,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -2,13 +2,12 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import size from "lodash/size";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EIssueFilterType, EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EIssuesStoreType, EUserProjectRoles, IIssueFilterOptions, ISearchIssueResponse } from "@plane/types";
|
||||
import { EIssuesStoreType, EUserProjectRoles, ISearchIssueResponse } from "@plane/types";
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
|
||||
@ -18,11 +17,15 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useWorkItemFilterInstance } from "@/hooks/store/work-item-filters/use-work-item-filter-instance";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
export const CycleEmptyState: React.FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId, cycleId } = useParams();
|
||||
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, cycleId: routerCycleId } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
|
||||
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
|
||||
const cycleId = routerCycleId ? routerCycleId.toString() : undefined;
|
||||
// states
|
||||
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
|
||||
// plane hooks
|
||||
@ -33,16 +36,10 @@ export const CycleEmptyState: React.FC = observer(() => {
|
||||
const { toggleCreateIssueModal } = useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
const userFilters = issuesFilter?.issueFilters?.filters;
|
||||
const cycleWorkItemFilter = cycleId ? useWorkItemFilterInstance(EIssuesStoreType.CYCLE, cycleId) : undefined;
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId) : undefined;
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
|
||||
const issueFilterCount = size(
|
||||
Object.fromEntries(
|
||||
Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0)
|
||||
)
|
||||
);
|
||||
const isCompletedCycleSnapshotAvailable = !isEmpty(cycleDetails?.progress_snapshot ?? {});
|
||||
const isEmptyFilters = issueFilterCount > 0;
|
||||
const isCompletedAndEmpty = isCompletedCycleSnapshotAvailable || cycleDetails?.status?.toLowerCase() === "completed";
|
||||
const additionalPath = activeLayout ?? "list";
|
||||
const canPerformEmptyStateActions = allowPermissions(
|
||||
@ -84,23 +81,6 @@ export const CycleEmptyState: React.FC = observer(() => {
|
||||
);
|
||||
};
|
||||
|
||||
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()
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<ExistingIssuesListModal
|
||||
@ -118,14 +98,14 @@ export const CycleEmptyState: React.FC = observer(() => {
|
||||
description={t("project_cycles.empty_state.completed_no_issues.description")}
|
||||
assetPath={completedNoIssuesResolvedPath}
|
||||
/>
|
||||
) : isEmptyFilters ? (
|
||||
) : cycleWorkItemFilter?.hasActiveFilters ? (
|
||||
<DetailedEmptyState
|
||||
title={t("project_issues.empty_state.issues_empty_filter.title")}
|
||||
assetPath={emptyFilterResolvedPath}
|
||||
secondaryButton={{
|
||||
text: t("project_issues.empty_state.issues_empty_filter.secondary_button.text"),
|
||||
onClick: handleClearAllFilters,
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
onClick: cycleWorkItemFilter?.clearFilters,
|
||||
disabled: !canPerformEmptyStateActions || !cycleWorkItemFilter,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
// plane web components
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { TeamEmptyState, TeamViewEmptyState } from "@/plane-web/components/issues/issue-layouts/empty-states";
|
||||
import { TeamEmptyState } from "@/plane-web/components/issues/issue-layouts/empty-states/team-issues";
|
||||
import { TeamProjectWorkItemEmptyState } from "@/plane-web/components/issues/issue-layouts/empty-states/team-project";
|
||||
import { TeamViewEmptyState } from "@/plane-web/components/issues/issue-layouts/empty-states/team-view-issues";
|
||||
// components
|
||||
import { ProjectArchivedEmptyState } from "./archived-issues";
|
||||
import { CycleEmptyState } from "./cycle";
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import size from "lodash/size";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EIssueFilterType, EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EIssuesStoreType, EUserProjectRoles, IIssueFilterOptions, ISearchIssueResponse } from "@plane/types";
|
||||
import { EIssuesStoreType, EUserProjectRoles, ISearchIssueResponse } from "@plane/types";
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal";
|
||||
@ -17,11 +16,15 @@ import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useWorkItemFilterInstance } from "@/hooks/store/work-item-filters/use-work-item-filter-instance";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
export const ModuleEmptyState: React.FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId, moduleId } = useParams();
|
||||
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, moduleId: routerModuleId } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
|
||||
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
|
||||
const moduleId = routerModuleId ? routerModuleId.toString() : undefined;
|
||||
// states
|
||||
const [moduleIssuesListModal, setModuleIssuesListModal] = useState(false);
|
||||
// plane hooks
|
||||
@ -31,14 +34,8 @@ export const ModuleEmptyState: React.FC = observer(() => {
|
||||
const { toggleCreateIssueModal } = useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const userFilters = issuesFilter?.issueFilters?.filters;
|
||||
const moduleWorkItemFilter = moduleId ? useWorkItemFilterInstance(EIssuesStoreType.MODULE, moduleId) : undefined;
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
|
||||
const issueFilterCount = size(
|
||||
Object.fromEntries(
|
||||
Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0)
|
||||
)
|
||||
);
|
||||
const isEmptyFilters = issueFilterCount > 0;
|
||||
const additionalPath = activeLayout ?? "list";
|
||||
const canPerformEmptyStateActions = allowPermissions(
|
||||
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
|
||||
@ -75,23 +72,6 @@ export const ModuleEmptyState: React.FC = observer(() => {
|
||||
);
|
||||
};
|
||||
|
||||
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()
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<ExistingIssuesListModal
|
||||
@ -103,14 +83,14 @@ export const ModuleEmptyState: React.FC = observer(() => {
|
||||
handleOnSubmit={handleAddIssuesToModule}
|
||||
/>
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
{isEmptyFilters ? (
|
||||
{moduleWorkItemFilter?.hasActiveFilters ? (
|
||||
<DetailedEmptyState
|
||||
title={t("project_issues.empty_state.issues_empty_filter.title")}
|
||||
assetPath={emptyFilterResolvedPath}
|
||||
secondaryButton={{
|
||||
text: t("project_issues.empty_state.issues_empty_filter.secondary_button.text"),
|
||||
onClick: handleClearAllFilters,
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
onClick: moduleWorkItemFilter?.clearFilters,
|
||||
disabled: !canPerformEmptyStateActions || !moduleWorkItemFilter,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import size from "lodash/size";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EIssueFilterType, EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EUserPermissionsLevel, WORK_ITEM_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EIssuesStoreType, EUserProjectRoles, IIssueFilterOptions } from "@plane/types";
|
||||
import { EIssuesStoreType, EUserProjectRoles } from "@plane/types";
|
||||
// components
|
||||
import { ComicBoxButton } from "@/components/empty-state/comic-box-button";
|
||||
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
|
||||
@ -13,11 +12,13 @@ import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useWorkItemFilterInstance } from "@/hooks/store/work-item-filters/use-work-item-filter-instance";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
export const ProjectEmptyState: React.FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const { projectId: routerProjectId } = useParams();
|
||||
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
|
||||
// plane imports
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
@ -25,14 +26,9 @@ export const ProjectEmptyState: React.FC = observer(() => {
|
||||
const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const userFilters = issuesFilter?.issueFilters?.filters;
|
||||
const projectWorkItemFilter = projectId ? useWorkItemFilterInstance(EIssuesStoreType.PROJECT, projectId) : undefined;
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
|
||||
const issueFilterCount = size(
|
||||
Object.fromEntries(
|
||||
Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0)
|
||||
)
|
||||
);
|
||||
const additionalPath = issueFilterCount > 0 ? (activeLayout ?? "list") : undefined;
|
||||
const additionalPath = projectWorkItemFilter?.hasActiveFilters ? (activeLayout ?? "list") : undefined;
|
||||
const canPerformEmptyStateActions = allowPermissions(
|
||||
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
@ -45,27 +41,16 @@ export const ProjectEmptyState: React.FC = observer(() => {
|
||||
basePath: "/empty-state/onboarding/issues",
|
||||
});
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = [];
|
||||
});
|
||||
issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
|
||||
...newFilters,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
{issueFilterCount > 0 ? (
|
||||
{projectWorkItemFilter?.hasActiveFilters ? (
|
||||
<DetailedEmptyState
|
||||
title={t("project_issues.empty_state.issues_empty_filter.title")}
|
||||
assetPath={emptyFilterResolvedPath}
|
||||
secondaryButton={{
|
||||
text: t("project_issues.empty_state.issues_empty_filter.secondary_button.text"),
|
||||
onClick: handleClearAllFilters,
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
onClick: projectWorkItemFilter?.clearFilters,
|
||||
disabled: !canPerformEmptyStateActions || !projectWorkItemFilter,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -1,166 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IIssueFilterOptions, IIssueLabel, IState } from "@plane/types";
|
||||
import { Tag } from "@plane/ui";
|
||||
import { replaceUnderscoreIfSnakeCase } from "@plane/utils";
|
||||
// components
|
||||
import {
|
||||
AppliedCycleFilters,
|
||||
AppliedDateFilters,
|
||||
AppliedLabelsFilters,
|
||||
AppliedMembersFilters,
|
||||
AppliedModuleFilters,
|
||||
AppliedPriorityFilters,
|
||||
AppliedProjectFilters,
|
||||
AppliedStateFilters,
|
||||
AppliedStateGroupFilters,
|
||||
} from "@/components/issues/issue-layouts/filters";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web components
|
||||
import { AppliedIssueTypeFilters } from "@/plane-web/components/issues/filters/applied-filters/issue-types";
|
||||
|
||||
type Props = {
|
||||
appliedFilters: IIssueFilterOptions;
|
||||
handleClearAllFilters: () => void;
|
||||
handleRemoveFilter: (key: keyof IIssueFilterOptions, value: string | null) => void;
|
||||
labels?: IIssueLabel[] | undefined;
|
||||
states?: IState[] | undefined;
|
||||
alwaysAllowEditing?: boolean;
|
||||
disableEditing?: boolean;
|
||||
};
|
||||
|
||||
const membersFilters = ["assignees", "mentions", "created_by", "subscriber"];
|
||||
const dateFilters = ["start_date", "target_date"];
|
||||
|
||||
export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
appliedFilters,
|
||||
handleClearAllFilters,
|
||||
handleRemoveFilter,
|
||||
labels,
|
||||
states,
|
||||
alwaysAllowEditing,
|
||||
disableEditing = false,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!appliedFilters) return null;
|
||||
|
||||
if (Object.keys(appliedFilters).length === 0) return null;
|
||||
|
||||
const isEditingAllowed =
|
||||
!disableEditing &&
|
||||
(alwaysAllowEditing ||
|
||||
allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT));
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100 truncate my-auto">
|
||||
{Object.entries(appliedFilters).map(([key, value]) => {
|
||||
const filterKey = key as keyof IIssueFilterOptions;
|
||||
|
||||
if (!value) return;
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
|
||||
return (
|
||||
<Tag key={filterKey}>
|
||||
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
|
||||
{membersFilters.includes(filterKey) && (
|
||||
<AppliedMembersFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
|
||||
values={value}
|
||||
/>
|
||||
)}
|
||||
{dateFilters.includes(filterKey) && (
|
||||
<AppliedDateFilters handleRemove={(val) => handleRemoveFilter(filterKey, val)} values={value} />
|
||||
)}
|
||||
{filterKey === "labels" && (
|
||||
<AppliedLabelsFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter("labels", val)}
|
||||
labels={labels}
|
||||
values={value}
|
||||
/>
|
||||
)}
|
||||
{filterKey === "priority" && (
|
||||
<AppliedPriorityFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter("priority", val)}
|
||||
values={value}
|
||||
/>
|
||||
)}
|
||||
{filterKey === "state" && states && (
|
||||
<AppliedStateFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter("state", val)}
|
||||
states={states}
|
||||
values={value}
|
||||
/>
|
||||
)}
|
||||
{filterKey === "state_group" && (
|
||||
<AppliedStateGroupFilters handleRemove={(val) => handleRemoveFilter("state_group", val)} values={value} />
|
||||
)}
|
||||
{filterKey === "project" && (
|
||||
<AppliedProjectFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter("project", val)}
|
||||
values={value}
|
||||
/>
|
||||
)}
|
||||
{filterKey === "cycle" && (
|
||||
<AppliedCycleFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter("cycle", val)}
|
||||
values={value}
|
||||
/>
|
||||
)}
|
||||
{filterKey === "module" && (
|
||||
<AppliedModuleFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter("module", val)}
|
||||
values={value}
|
||||
/>
|
||||
)}
|
||||
{filterKey === "issue_type" && (
|
||||
<AppliedIssueTypeFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter("issue_type", val)}
|
||||
values={value}
|
||||
/>
|
||||
)}
|
||||
{filterKey === "team_project" && (
|
||||
<AppliedProjectFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter("team_project", val)}
|
||||
values={value}
|
||||
/>
|
||||
)}
|
||||
{isEditingAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
onClick={() => handleRemoveFilter(filterKey, null)}
|
||||
>
|
||||
<X size={12} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{isEditingAllowed && (
|
||||
<button type="button" onClick={handleClearAllFilters}>
|
||||
<Tag>
|
||||
{t("common.clear_all")}
|
||||
<X size={12} strokeWidth={2} />
|
||||
</Tag>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@ -1,6 +1,4 @@
|
||||
export * from "./roots";
|
||||
export * from "./date";
|
||||
export * from "./filters-list";
|
||||
export * from "./label";
|
||||
export * from "./members";
|
||||
export * from "./priority";
|
||||
|
||||
@ -1,82 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
// local imports
|
||||
import { AppliedFiltersList } from "../filters-list";
|
||||
|
||||
export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
|
||||
// store hooks
|
||||
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
const { projectLabels } = useLabel();
|
||||
const { projectStates } = useProjectState();
|
||||
// derived values
|
||||
const userFilters = issueFilters?.filters;
|
||||
// filters whose value not null or empty array
|
||||
const appliedFilters: IIssueFilterOptions = {};
|
||||
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
|
||||
if (!value) return;
|
||||
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
|
||||
appliedFilters[key as keyof IIssueFilterOptions] = value;
|
||||
});
|
||||
|
||||
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
// remove all values of the key if value is null
|
||||
if (!value) {
|
||||
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
|
||||
[key]: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// remove the passed value from the key
|
||||
let newValues = issueFilters?.filters?.[key] ?? [];
|
||||
newValues = newValues.filter((val) => val !== value);
|
||||
|
||||
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
|
||||
[key]: newValues,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = [];
|
||||
});
|
||||
|
||||
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
|
||||
...newFilters,
|
||||
});
|
||||
};
|
||||
|
||||
// return if no filters are applied
|
||||
if (Object.keys(appliedFilters).length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex justify-between p-4 gap-2.5">
|
||||
<AppliedFiltersList
|
||||
appliedFilters={appliedFilters}
|
||||
handleClearAllFilters={handleClearAllFilters}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
labels={projectLabels ?? []}
|
||||
states={projectStates}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@ -1,105 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EIssueFilterType, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
// local imports
|
||||
import { SaveFilterView } from "../../../save-filter-view";
|
||||
import { AppliedFiltersList } from "../filters-list";
|
||||
|
||||
export const CycleAppliedFiltersRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId, cycleId } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
|
||||
const { projectLabels } = useLabel();
|
||||
const { projectStates } = useProjectState();
|
||||
// derived values
|
||||
const userFilters = issueFilters?.filters;
|
||||
// filters whose value not null or empty array
|
||||
const appliedFilters: IIssueFilterOptions = {};
|
||||
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
|
||||
if (!value) return;
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
appliedFilters[key as keyof IIssueFilterOptions] = value;
|
||||
});
|
||||
|
||||
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
if (!value) {
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{
|
||||
[key]: null,
|
||||
},
|
||||
cycleId.toString()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let newValues = issueFilters?.filters?.[key] ?? [];
|
||||
newValues = newValues.filter((val) => val !== value);
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{
|
||||
[key]: newValues,
|
||||
},
|
||||
cycleId.toString()
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = [];
|
||||
});
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{ ...newFilters },
|
||||
cycleId.toString()
|
||||
);
|
||||
};
|
||||
|
||||
// return if no filters are applied
|
||||
if (Object.keys(appliedFilters).length === 0 || !workspaceSlug || !projectId || !cycleId) return null;
|
||||
|
||||
return (
|
||||
<Header variant={EHeaderVariant.TERNARY}>
|
||||
<Header.LeftItem>
|
||||
<AppliedFiltersList
|
||||
appliedFilters={appliedFilters}
|
||||
handleClearAllFilters={handleClearAllFilters}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
labels={projectLabels ?? []}
|
||||
states={projectStates}
|
||||
/>
|
||||
</Header.LeftItem>
|
||||
<SaveFilterView
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
filterParams={{
|
||||
filters: { ...appliedFilters, cycle: [cycleId?.toString()] },
|
||||
display_filters: issueFilters?.displayFilters,
|
||||
display_properties: issueFilters?.displayProperties,
|
||||
}}
|
||||
trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.CYCLE_HEADER_SAVE_AS_VIEW_BUTTON}
|
||||
/>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
@ -1,203 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// Plane imports
|
||||
import {
|
||||
DEFAULT_GLOBAL_VIEWS_LIST,
|
||||
EIssueFilterType,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
GLOBAL_VIEW_TRACKER_ELEMENTS,
|
||||
GLOBAL_VIEW_TRACKER_EVENTS,
|
||||
} from "@plane/constants";
|
||||
import { EIssuesStoreType, EViewAccess, IIssueFilterOptions, TStaticViewTypes } from "@plane/types";
|
||||
import { Header, EHeaderVariant, Loader } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { UpdateViewComponent } from "@/components/views/update-view-component";
|
||||
import { CreateUpdateWorkspaceViewModal } from "@/components/workspace/views/modal";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useGlobalView } from "@/hooks/store/use-global-view";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { getAreFiltersEqual } from "../../../utils";
|
||||
import { AppliedFiltersList } from "../filters-list";
|
||||
|
||||
type Props = {
|
||||
globalViewId: string;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
|
||||
const { globalViewId, isLoading = false } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { filters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.GLOBAL);
|
||||
const { workspaceLabels } = useLabel();
|
||||
const { globalViewMap, updateGlobalView } = useGlobalView();
|
||||
const { data } = useUser();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// derived values
|
||||
const issueFilters = filters?.[globalViewId];
|
||||
const userFilters = issueFilters?.filters;
|
||||
const viewDetails = globalViewMap[globalViewId];
|
||||
|
||||
// filters whose value not null or empty array
|
||||
let appliedFilters: IIssueFilterOptions | undefined = undefined;
|
||||
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
|
||||
if (!value) return;
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
if (!appliedFilters) appliedFilters = {};
|
||||
appliedFilters[key as keyof IIssueFilterOptions] = value;
|
||||
});
|
||||
|
||||
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
|
||||
if (!workspaceSlug || !globalViewId) return;
|
||||
|
||||
if (!value) {
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: null },
|
||||
globalViewId.toString()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let newValues = userFilters?.[key] ?? [];
|
||||
newValues = newValues.filter((val) => val !== value);
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
globalViewId.toString()
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !globalViewId) return;
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = [];
|
||||
});
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.FILTERS,
|
||||
{ ...newFilters },
|
||||
globalViewId.toString()
|
||||
);
|
||||
};
|
||||
|
||||
const viewFilters = {
|
||||
filters: cloneDeep(appliedFilters ?? {}),
|
||||
display_filters: cloneDeep(issueFilters?.displayFilters),
|
||||
display_properties: cloneDeep(issueFilters?.displayProperties),
|
||||
};
|
||||
const handleUpdateView = () => {
|
||||
if (!workspaceSlug || !globalViewId) return;
|
||||
|
||||
updateGlobalView(workspaceSlug.toString(), globalViewId.toString(), viewFilters)
|
||||
.then((res) => {
|
||||
if (res)
|
||||
captureSuccess({
|
||||
eventName: GLOBAL_VIEW_TRACKER_EVENTS.update,
|
||||
payload: {
|
||||
view_id: globalViewId,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
captureError({
|
||||
eventName: GLOBAL_VIEW_TRACKER_EVENTS.update,
|
||||
payload: {
|
||||
view_id: globalViewId,
|
||||
},
|
||||
error: error,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// add a placeholder object instead of appliedFilters if it is undefined
|
||||
const areFiltersEqual = getAreFiltersEqual(appliedFilters ?? {}, issueFilters, viewDetails);
|
||||
|
||||
const isAuthorizedUser = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
const isDefaultView = DEFAULT_GLOBAL_VIEWS_LIST.map((view) => view.key).includes(globalViewId as TStaticViewTypes);
|
||||
|
||||
const isLocked = viewDetails?.is_locked;
|
||||
const isOwner = viewDetails?.owned_by === data?.id;
|
||||
const areAppliedFiltersEmpty = isEmpty(appliedFilters);
|
||||
|
||||
// return if no filters are applied
|
||||
|
||||
if (areAppliedFiltersEmpty && areFiltersEqual) return null;
|
||||
|
||||
return (
|
||||
<Header
|
||||
variant={EHeaderVariant.TERNARY}
|
||||
className={cn({
|
||||
"justify-end": areAppliedFiltersEmpty,
|
||||
})}
|
||||
>
|
||||
<CreateUpdateWorkspaceViewModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
preLoadedData={{
|
||||
name: `${viewDetails?.name} 2`,
|
||||
description: viewDetails?.description,
|
||||
access: viewDetails?.access ?? EViewAccess.PUBLIC,
|
||||
...viewFilters,
|
||||
}}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Loader className="flex flex-wrap items-stretch gap-2 bg-custom-background-100 truncate my-auto">
|
||||
<Loader.Item height="36px" width="150px" />
|
||||
<Loader.Item height="36px" width="100px" />
|
||||
<Loader.Item height="36px" width="300px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<AppliedFiltersList
|
||||
labels={workspaceLabels ?? undefined}
|
||||
appliedFilters={appliedFilters ?? {}}
|
||||
handleClearAllFilters={handleClearAllFilters}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
disableEditing={isLocked}
|
||||
alwaysAllowEditing
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isDefaultView ? (
|
||||
<UpdateViewComponent
|
||||
isLocked={isLocked}
|
||||
areFiltersEqual={!!areFiltersEqual}
|
||||
isOwner={isOwner}
|
||||
isAuthorizedUser={isAuthorizedUser}
|
||||
setIsModalOpen={setIsModalOpen}
|
||||
handleUpdateView={handleUpdateView}
|
||||
trackerElement={GLOBAL_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
@ -1,7 +0,0 @@
|
||||
export * from "./cycle-root";
|
||||
export * from "./global-view-root";
|
||||
export * from "./module-root";
|
||||
export * from "./project-view-root";
|
||||
export * from "./project-root";
|
||||
export * from "./archived-issue";
|
||||
export * from "./profile-issues-root";
|
||||
@ -1,103 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EIssueFilterType, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { SaveFilterView } from "../../../save-filter-view";
|
||||
import { AppliedFiltersList } from "../filters-list";
|
||||
|
||||
export const ModuleAppliedFiltersRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId, moduleId } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.MODULE);
|
||||
const { projectLabels } = useLabel();
|
||||
const { projectStates } = useProjectState();
|
||||
// derived values
|
||||
const userFilters = issueFilters?.filters;
|
||||
// filters whose value not null or empty array
|
||||
const appliedFilters: IIssueFilterOptions = {};
|
||||
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
|
||||
if (!value) return;
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
appliedFilters[key as keyof IIssueFilterOptions] = value;
|
||||
});
|
||||
|
||||
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
|
||||
if (!workspaceSlug || !projectId || !moduleId) return;
|
||||
if (!value) {
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{
|
||||
[key]: null,
|
||||
},
|
||||
moduleId.toString()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let newValues = issueFilters?.filters?.[key] ?? [];
|
||||
newValues = newValues.filter((val) => val !== value);
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{
|
||||
[key]: newValues,
|
||||
},
|
||||
moduleId.toString()
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !projectId || !moduleId) return;
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = [];
|
||||
});
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{ ...newFilters },
|
||||
moduleId.toString()
|
||||
);
|
||||
};
|
||||
|
||||
// return if no filters are applied
|
||||
if (!workspaceSlug || !projectId || !moduleId || Object.keys(appliedFilters).length === 0) return null;
|
||||
|
||||
return (
|
||||
<Header variant={EHeaderVariant.TERNARY}>
|
||||
<Header.LeftItem>
|
||||
<AppliedFiltersList
|
||||
appliedFilters={appliedFilters}
|
||||
handleClearAllFilters={handleClearAllFilters}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
labels={projectLabels ?? []}
|
||||
states={projectStates}
|
||||
/>
|
||||
</Header.LeftItem>
|
||||
<SaveFilterView
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
filterParams={{
|
||||
filters: { ...appliedFilters, module: [moduleId.toString()] },
|
||||
display_filters: issueFilters?.displayFilters,
|
||||
display_properties: issueFilters?.displayProperties,
|
||||
}}
|
||||
trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.MODULE_HEADER_SAVE_AS_VIEW_BUTTON}
|
||||
/>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
@ -1,78 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
|
||||
import { AppliedFiltersList } from "../filters-list";
|
||||
|
||||
export const ProfileIssuesAppliedFiltersRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, userId } = useParams();
|
||||
//swr hook for fetching issue properties
|
||||
useWorkspaceIssueProperties(workspaceSlug);
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.PROFILE);
|
||||
|
||||
const { workspaceLabels } = useLabel();
|
||||
// derived values
|
||||
const userFilters = issueFilters?.filters;
|
||||
|
||||
// filters whose value not null or empty array
|
||||
const appliedFilters: IIssueFilterOptions = {};
|
||||
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
|
||||
if (!value) return;
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
appliedFilters[key as keyof IIssueFilterOptions] = value;
|
||||
});
|
||||
|
||||
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
|
||||
if (!workspaceSlug || !userId) return;
|
||||
if (!value) {
|
||||
updateFilters(workspaceSlug.toString(), undefined, EIssueFilterType.FILTERS, { [key]: null }, userId.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
let newValues = issueFilters?.filters?.[key] ?? [];
|
||||
newValues = newValues.filter((val) => val !== value);
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.FILTERS,
|
||||
{
|
||||
[key]: newValues,
|
||||
},
|
||||
userId.toString()
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !userId) return;
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = [];
|
||||
});
|
||||
updateFilters(workspaceSlug.toString(), undefined, EIssueFilterType.FILTERS, { ...newFilters }, userId.toString());
|
||||
};
|
||||
|
||||
// return if no filters are applied
|
||||
if (Object.keys(appliedFilters).length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4 flex-shrink-0">
|
||||
<AppliedFiltersList
|
||||
appliedFilters={appliedFilters}
|
||||
handleClearAllFilters={handleClearAllFilters}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
labels={workspaceLabels ?? []}
|
||||
states={[]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@ -1,108 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import {
|
||||
EIssueFilterType,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
PROJECT_VIEW_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types";
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { SaveFilterView } from "../../../save-filter-view";
|
||||
import { AppliedFiltersList } from "../filters-list";
|
||||
|
||||
type TProjectAppliedFiltersRootProps = {
|
||||
storeType?: EIssuesStoreType.PROJECT | EIssuesStoreType.EPIC;
|
||||
};
|
||||
|
||||
export const ProjectAppliedFiltersRoot: React.FC<TProjectAppliedFiltersRootProps> = observer((props) => {
|
||||
const { storeType = EIssuesStoreType.PROJECT } = props;
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(storeType);
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const { projectStates } = useProjectState();
|
||||
// derived values
|
||||
const isEditingAllowed = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
const userFilters = issueFilters?.filters;
|
||||
// filters whose value not null or empty array
|
||||
const appliedFilters: IIssueFilterOptions = {};
|
||||
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
|
||||
if (!value) return;
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
appliedFilters[key as keyof IIssueFilterOptions] = value;
|
||||
});
|
||||
|
||||
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
if (!value) {
|
||||
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
|
||||
[key]: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let newValues = issueFilters?.filters?.[key] ?? [];
|
||||
newValues = newValues.filter((val) => val !== value);
|
||||
|
||||
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, {
|
||||
[key]: newValues,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = [];
|
||||
});
|
||||
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { ...newFilters });
|
||||
};
|
||||
|
||||
// return if no filters are applied
|
||||
if (Object.keys(appliedFilters).length === 0) return null;
|
||||
|
||||
return (
|
||||
<Header variant={EHeaderVariant.TERNARY}>
|
||||
<Header.LeftItem>
|
||||
<AppliedFiltersList
|
||||
appliedFilters={appliedFilters}
|
||||
handleClearAllFilters={handleClearAllFilters}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
labels={projectLabels ?? []}
|
||||
states={projectStates}
|
||||
alwaysAllowEditing
|
||||
/>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
{isEditingAllowed && storeType === EIssuesStoreType.PROJECT && (
|
||||
<SaveFilterView
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
filterParams={{
|
||||
filters: appliedFilters,
|
||||
display_filters: issueFilters?.displayFilters,
|
||||
display_properties: issueFilters?.displayProperties,
|
||||
}}
|
||||
trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.PROJECT_HEADER_SAVE_AS_VIEW_BUTTON}
|
||||
/>
|
||||
)}
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
@ -1,162 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
import {
|
||||
EIssueFilterType,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
PROJECT_VIEW_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
import { EIssuesStoreType, EViewAccess, IIssueFilterOptions } from "@plane/types";
|
||||
// components
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
import { CreateUpdateProjectViewModal } from "@/components/views/modal";
|
||||
import { UpdateViewComponent } from "@/components/views/update-view-component";
|
||||
// constants
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useProjectView } from "@/hooks/store/use-project-view";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { getAreFiltersEqual } from "../../../utils";
|
||||
import { AppliedFiltersList } from "../filters-list";
|
||||
|
||||
export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId, viewId } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.PROJECT_VIEW);
|
||||
const { projectLabels } = useLabel();
|
||||
const { projectStates } = useProjectState();
|
||||
const { viewMap, updateView } = useProjectView();
|
||||
const { data } = useUser();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
// derived values
|
||||
const viewDetails = viewId ? viewMap[viewId.toString()] : null;
|
||||
const userFilters = issueFilters?.filters;
|
||||
// filters whose value not null or empty array
|
||||
let appliedFilters: IIssueFilterOptions | undefined = undefined;
|
||||
Object.entries(userFilters ?? {}).forEach(([key, value]) => {
|
||||
if (!value) return;
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
if (!appliedFilters) appliedFilters = {};
|
||||
appliedFilters[key as keyof IIssueFilterOptions] = value;
|
||||
});
|
||||
|
||||
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
|
||||
if (!workspaceSlug || !projectId || !viewId) return;
|
||||
if (!value) {
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{
|
||||
[key]: null,
|
||||
},
|
||||
viewId.toString()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let newValues = issueFilters?.filters?.[key] ?? [];
|
||||
newValues = newValues.filter((val) => val !== value);
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{
|
||||
[key]: newValues,
|
||||
},
|
||||
viewId.toString()
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
if (!workspaceSlug || !projectId || !viewId) return;
|
||||
const newFilters: IIssueFilterOptions = {};
|
||||
Object.keys(userFilters ?? {}).forEach((key) => {
|
||||
newFilters[key as keyof IIssueFilterOptions] = [];
|
||||
});
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{ ...newFilters },
|
||||
viewId.toString()
|
||||
);
|
||||
};
|
||||
|
||||
// add a placeholder object instead of appliedFilters if it is undefined
|
||||
const areFiltersEqual = getAreFiltersEqual(appliedFilters ?? {}, issueFilters, viewDetails);
|
||||
const viewFilters = {
|
||||
filters: cloneDeep(appliedFilters ?? {}),
|
||||
display_filters: cloneDeep(issueFilters?.displayFilters),
|
||||
display_properties: cloneDeep(issueFilters?.displayProperties),
|
||||
};
|
||||
// return if no filters are applied
|
||||
if (isEmpty(appliedFilters) && areFiltersEqual) return null;
|
||||
|
||||
const handleUpdateView = () => {
|
||||
if (!workspaceSlug || !projectId || !viewId || !viewDetails) return;
|
||||
|
||||
updateView(workspaceSlug.toString(), projectId.toString(), viewId.toString(), viewFilters);
|
||||
};
|
||||
|
||||
const isAuthorizedUser = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
const isLocked = !!viewDetails?.is_locked;
|
||||
const isOwner = viewDetails?.owned_by === data?.id;
|
||||
|
||||
return (
|
||||
<Header variant={EHeaderVariant.TERNARY}>
|
||||
<CreateUpdateProjectViewModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
preLoadedData={{
|
||||
name: `${viewDetails?.name} 2`,
|
||||
description: viewDetails?.description,
|
||||
logo_props: viewDetails?.logo_props,
|
||||
access: viewDetails?.access ?? EViewAccess.PUBLIC,
|
||||
...viewFilters,
|
||||
}}
|
||||
/>
|
||||
<Header.LeftItem className="w-[70%]">
|
||||
<AppliedFiltersList
|
||||
appliedFilters={appliedFilters ?? {}}
|
||||
handleClearAllFilters={handleClearAllFilters}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
labels={projectLabels ?? []}
|
||||
states={projectStates}
|
||||
disableEditing={isLocked}
|
||||
/>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
<UpdateViewComponent
|
||||
isLocked={isLocked}
|
||||
areFiltersEqual={!!areFiltersEqual}
|
||||
isOwner={isOwner}
|
||||
isAuthorizedUser={isAuthorizedUser}
|
||||
setIsModalOpen={setIsModalOpen}
|
||||
handleUpdateView={handleUpdateView}
|
||||
trackerElement={PROJECT_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON}
|
||||
/>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
// icons
|
||||
import { X } from "lucide-react";
|
||||
// plane imports
|
||||
import { EIconSize } from "@plane/constants";
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import { IState } from "@plane/types";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
handleRemove: (val: string) => void;
|
||||
|
||||
@ -2,6 +2,5 @@ export * from "./display-filters-selection";
|
||||
export * from "./display-properties";
|
||||
export * from "./extra-options";
|
||||
export * from "./group-by";
|
||||
export * from "./issue-grouping";
|
||||
export * from "./order-by";
|
||||
export * from "./sub-group-by";
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { TIssueGroupingFilters } from "@plane/types";
|
||||
// components
|
||||
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
selectedIssueType: TIssueGroupingFilters | undefined;
|
||||
handleUpdate: (val: TIssueGroupingFilters) => void;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
const ISSUE_FILTER_OPTIONS: {
|
||||
key: TIssueGroupingFilters;
|
||||
title: string;
|
||||
}[] = [
|
||||
{ key: null, title: "All" },
|
||||
{ key: "active", title: "Active" },
|
||||
{ key: "backlog", title: "Backlog" },
|
||||
];
|
||||
|
||||
export const FilterIssueGrouping: React.FC<Props> = observer((props) => {
|
||||
const { selectedIssueType, handleUpdate, isEpic = false } = props;
|
||||
|
||||
const [previewEnabled, setPreviewEnabled] = React.useState(true);
|
||||
|
||||
const activeIssueType = selectedIssueType ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
title={`${isEpic ? "Epic" : "Work item"} Grouping`}
|
||||
isPreviewEnabled={previewEnabled}
|
||||
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||
/>
|
||||
{previewEnabled && (
|
||||
<div>
|
||||
{ISSUE_FILTER_OPTIONS.map((issueType) => (
|
||||
<FilterOption
|
||||
key={issueType?.key}
|
||||
isChecked={activeIssueType === issueType?.key ? true : false}
|
||||
onClick={() => handleUpdate(issueType?.key)}
|
||||
title={`${issueType.title} ${isEpic ? "Epics" : "Work items"}`}
|
||||
multiple={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@ -1,289 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Search, X } from "lucide-react";
|
||||
// plane imports
|
||||
import { EUserPermissions } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import {
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueFilterOptions,
|
||||
IIssueLabel,
|
||||
ILayoutDisplayFiltersOptions,
|
||||
IState,
|
||||
} from "@plane/types";
|
||||
// components
|
||||
import {
|
||||
FilterAssignees,
|
||||
FilterMentions,
|
||||
FilterCreatedBy,
|
||||
FilterDueDate,
|
||||
FilterLabels,
|
||||
FilterPriority,
|
||||
FilterProjects,
|
||||
FilterStartDate,
|
||||
FilterState,
|
||||
FilterStateGroup,
|
||||
FilterCycle,
|
||||
FilterModule,
|
||||
FilterIssueGrouping,
|
||||
} from "@/components/issues/issue-layouts/filters";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web imports
|
||||
import { FilterIssueTypes } from "@/plane-web/components/issues/filters/issue-types";
|
||||
import { FilterTeamProjects } from "@/plane-web/components/issues/filters/team-project";
|
||||
|
||||
type Props = {
|
||||
filters: IIssueFilterOptions;
|
||||
displayFilters?: IIssueDisplayFilterOptions | undefined;
|
||||
handleDisplayFiltersUpdate?: (updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => void;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined;
|
||||
projectId?: string;
|
||||
labels?: IIssueLabel[] | undefined;
|
||||
memberIds?: string[] | undefined;
|
||||
states?: IState[] | undefined;
|
||||
cycleViewDisabled?: boolean;
|
||||
moduleViewDisabled?: boolean;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
filters,
|
||||
displayFilters,
|
||||
handleDisplayFiltersUpdate,
|
||||
handleFiltersUpdate,
|
||||
layoutDisplayFiltersOptions,
|
||||
projectId,
|
||||
labels,
|
||||
memberIds,
|
||||
states,
|
||||
cycleViewDisabled = false,
|
||||
moduleViewDisabled = false,
|
||||
isEpic = false,
|
||||
} = props;
|
||||
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { moduleId, cycleId } = useParams();
|
||||
const {
|
||||
project: { getProjectMemberDetails },
|
||||
} = useMember();
|
||||
// states
|
||||
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||
|
||||
// filter guests from assignees
|
||||
const assigneeIds = memberIds?.filter((id) => {
|
||||
if (projectId) {
|
||||
const memeberDetails = getProjectMemberDetails(id, projectId);
|
||||
const isGuest = (memeberDetails?.role || EUserPermissions.GUEST) === EUserPermissions.GUEST;
|
||||
if (isGuest && memeberDetails) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const isFilterEnabled = (filter: keyof IIssueFilterOptions) => layoutDisplayFiltersOptions?.filters.includes(filter);
|
||||
|
||||
const isDisplayFilterEnabled = (displayFilter: keyof IIssueDisplayFilterOptions) =>
|
||||
Object.keys(layoutDisplayFiltersOptions?.display_filters ?? {}).includes(displayFilter);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="bg-custom-background-100 p-2.5 pb-0">
|
||||
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
|
||||
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
|
||||
placeholder={t("common.search.label")}
|
||||
value={filtersSearchQuery}
|
||||
onChange={(e) => setFiltersSearchQuery(e.target.value)}
|
||||
autoFocus={!isMobile}
|
||||
/>
|
||||
{filtersSearchQuery !== "" && (
|
||||
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
|
||||
<X className="text-custom-text-300" size={12} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vertical-scrollbar scrollbar-sm h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5">
|
||||
{/* priority */}
|
||||
{isFilterEnabled("priority") && (
|
||||
<div className="py-2">
|
||||
<FilterPriority
|
||||
appliedFilters={filters.priority ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("priority", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* state group */}
|
||||
{isFilterEnabled("state_group") && (
|
||||
<div className="py-2">
|
||||
<FilterStateGroup
|
||||
appliedFilters={filters.state_group ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("state_group", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* state */}
|
||||
{isFilterEnabled("state") && (
|
||||
<div className="py-2">
|
||||
<FilterState
|
||||
appliedFilters={filters.state ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("state", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
states={states}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* issue type */}
|
||||
{isFilterEnabled("issue_type") && (
|
||||
<FilterIssueTypes
|
||||
appliedFilters={filters.issue_type ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("issue_type", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* assignees */}
|
||||
{isFilterEnabled("assignees") && (
|
||||
<div className="py-2">
|
||||
<FilterAssignees
|
||||
appliedFilters={filters.assignees ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("assignees", val)}
|
||||
memberIds={assigneeIds}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* cycle */}
|
||||
{isFilterEnabled("cycle") && !cycleId && !cycleViewDisabled && (
|
||||
<div className="py-2">
|
||||
<FilterCycle
|
||||
appliedFilters={filters.cycle ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("cycle", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* module */}
|
||||
{isFilterEnabled("module") && !moduleId && !moduleViewDisabled && (
|
||||
<div className="py-2">
|
||||
<FilterModule
|
||||
appliedFilters={filters.module ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("module", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* assignees */}
|
||||
{isFilterEnabled("mentions") && (
|
||||
<div className="py-2">
|
||||
<FilterMentions
|
||||
appliedFilters={filters.mentions ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("mentions", val)}
|
||||
memberIds={memberIds}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* created_by */}
|
||||
{isFilterEnabled("created_by") && (
|
||||
<div className="py-2">
|
||||
<FilterCreatedBy
|
||||
appliedFilters={filters.created_by ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("created_by", val)}
|
||||
memberIds={memberIds}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* labels */}
|
||||
{isFilterEnabled("labels") && (
|
||||
<div className="py-2">
|
||||
<FilterLabels
|
||||
appliedFilters={filters.labels ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("labels", val)}
|
||||
labels={labels}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* project */}
|
||||
{isFilterEnabled("project") && (
|
||||
<div className="py-2">
|
||||
<FilterProjects
|
||||
appliedFilters={filters.project ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("project", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* team project */}
|
||||
{isFilterEnabled("team_project") && (
|
||||
<div className="py-2">
|
||||
<FilterTeamProjects
|
||||
appliedFilters={filters.team_project ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("team_project", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* issue type */}
|
||||
{isDisplayFilterEnabled("type") && displayFilters && handleDisplayFiltersUpdate && (
|
||||
<div className="py-2">
|
||||
<FilterIssueGrouping
|
||||
selectedIssueType={displayFilters.type}
|
||||
handleUpdate={(val) =>
|
||||
handleDisplayFiltersUpdate({
|
||||
type: val,
|
||||
})
|
||||
}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* start_date */}
|
||||
{isFilterEnabled("start_date") && (
|
||||
<div className="py-2">
|
||||
<FilterStartDate
|
||||
appliedFilters={filters.start_date ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("start_date", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* target_date */}
|
||||
{isFilterEnabled("target_date") && (
|
||||
<div className="py-2">
|
||||
<FilterDueDate
|
||||
appliedFilters={filters.target_date ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("target_date", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@ -2,7 +2,6 @@ export * from "./assignee";
|
||||
export * from "./mentions";
|
||||
export * from "./created-by";
|
||||
export * from "./due-date";
|
||||
export * from "./filters-selection";
|
||||
export * from "./labels";
|
||||
export * from "./priority";
|
||||
export * from "./project";
|
||||
|
||||
@ -116,8 +116,10 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
|
||||
>
|
||||
<div className="inline-block line-clamp-1 truncate font-medium text-custom-text-100">{title}</div>
|
||||
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
|
||||
<div className="px-2.5">
|
||||
<WorkFlowGroupTree groupBy={groupBy} groupId={groupID} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!disableIssueCreation &&
|
||||
(renderExistingIssueModal ? (
|
||||
|
||||
@ -21,7 +21,7 @@ import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane-web components
|
||||
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
|
||||
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal";
|
||||
// helper
|
||||
import { ArchiveIssueModal } from "../../archive-issue-modal";
|
||||
import { DeleteIssueModal } from "../../delete-issue-modal";
|
||||
|
||||
@ -21,7 +21,7 @@ import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane-web components
|
||||
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
|
||||
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal";
|
||||
// helper
|
||||
import { ArchiveIssueModal } from "../../archive-issue-modal";
|
||||
import { DeleteIssueModal } from "../../delete-issue-modal";
|
||||
|
||||
@ -20,8 +20,8 @@ import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane-web components
|
||||
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns";
|
||||
// plane-web imports
|
||||
import { DuplicateWorkItemModal } from "@/plane-web/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal";
|
||||
// helper
|
||||
import { ArchiveIssueModal } from "../../archive-issue-modal";
|
||||
import { DeleteIssueModal } from "../../delete-issue-modal";
|
||||
|
||||
@ -1,19 +1,23 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { isEmpty } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { GLOBAL_VIEW_TRACKER_ELEMENTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
import { IssuePeekOverview } from "@/components/issues/peek-overview";
|
||||
import { WorkspaceActiveLayout } from "@/components/views/helper";
|
||||
import { WorkspaceLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/workspace-level";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
|
||||
// hooks
|
||||
import { useGlobalView } from "@/hooks/store/use-global-view";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
|
||||
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
|
||||
// store
|
||||
// public imports
|
||||
import emptyView from "@/public/empty-state/view.svg";
|
||||
|
||||
type Props = {
|
||||
@ -24,61 +28,44 @@ type Props = {
|
||||
|
||||
export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
const { isDefaultView, isLoading = false, toggleLoading } = props;
|
||||
|
||||
// Router hooks
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, globalViewId } = useParams();
|
||||
const { workspaceSlug: routerWorkspaceSlug, globalViewId: routerGlobalViewId } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
|
||||
const globalViewId = routerGlobalViewId ? routerGlobalViewId.toString() : undefined;
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Store hooks
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { fetchFilters, updateFilters },
|
||||
issuesFilter: { filters, fetchFilters, updateFilterExpression },
|
||||
issues: { clear, groupedIssueIds, fetchIssues, fetchNextIssues },
|
||||
} = useIssues(EIssuesStoreType.GLOBAL);
|
||||
const { fetchAllGlobalViews, getViewDetailsById } = useGlobalView();
|
||||
// Derived values
|
||||
const viewDetails = globalViewId ? getViewDetailsById(globalViewId) : undefined;
|
||||
const workItemFilters = globalViewId ? filters?.[globalViewId] : undefined;
|
||||
const activeLayout: EIssueLayoutTypes | undefined = workItemFilters?.displayFilters?.layout;
|
||||
const initialWorkItemFilters = viewDetails
|
||||
? {
|
||||
displayFilters: workItemFilters?.displayFilters,
|
||||
displayProperties: workItemFilters?.displayProperties,
|
||||
kanbanFilters: workItemFilters?.kanbanFilters,
|
||||
richFilters: viewDetails?.rich_filters ?? {},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Custom hooks
|
||||
useWorkspaceIssueProperties(workspaceSlug);
|
||||
|
||||
// Derived values
|
||||
const viewDetails = getViewDetailsById(globalViewId?.toString());
|
||||
const activeLayout: EIssueLayoutTypes | undefined = EIssueLayoutTypes.SPREADSHEET;
|
||||
|
||||
// Route filters
|
||||
const routeFilters: { [key: string]: string } = {};
|
||||
searchParams.forEach((value: string, key: string) => {
|
||||
routeFilters[key] = value;
|
||||
});
|
||||
|
||||
// Apply route filters to store
|
||||
const routerFilterParams = () => {
|
||||
if (
|
||||
workspaceSlug &&
|
||||
globalViewId &&
|
||||
["all-issues", "assigned", "created", "subscribed"].includes(globalViewId.toString())
|
||||
) {
|
||||
let issueFilters: any = {};
|
||||
Object.keys(routeFilters).forEach((key) => {
|
||||
const filterKey: any = key;
|
||||
const filterValue = routeFilters[key]?.toString() || undefined;
|
||||
if (ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.spreadsheet.filters.includes(filterKey) && filterKey && filterValue)
|
||||
issueFilters = { ...issueFilters, [filterKey]: filterValue.split(",") };
|
||||
});
|
||||
|
||||
if (!isEmpty(routeFilters))
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.FILTERS,
|
||||
issueFilters,
|
||||
globalViewId.toString()
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch next pages callback
|
||||
const fetchNextPages = useCallback(() => {
|
||||
if (workspaceSlug && globalViewId) fetchNextIssues(workspaceSlug.toString(), globalViewId.toString());
|
||||
if (workspaceSlug && globalViewId) fetchNextIssues(workspaceSlug, globalViewId);
|
||||
}, [fetchNextIssues, workspaceSlug, globalViewId]);
|
||||
|
||||
// Fetch global views
|
||||
@ -86,7 +73,7 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
workspaceSlug ? `WORKSPACE_GLOBAL_VIEWS_${workspaceSlug}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug) {
|
||||
await fetchAllGlobalViews(workspaceSlug.toString());
|
||||
await fetchAllGlobalViews(workspaceSlug);
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
@ -99,17 +86,11 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
if (workspaceSlug && globalViewId) {
|
||||
clear();
|
||||
toggleLoading(true);
|
||||
await fetchFilters(workspaceSlug.toString(), globalViewId.toString());
|
||||
await fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
globalViewId.toString(),
|
||||
groupedIssueIds ? "mutation" : "init-loader",
|
||||
{
|
||||
await fetchFilters(workspaceSlug, globalViewId);
|
||||
await fetchIssues(workspaceSlug, globalViewId, groupedIssueIds ? "mutation" : "init-loader", {
|
||||
canGroup: false,
|
||||
perPageCount: 100,
|
||||
}
|
||||
);
|
||||
routerFilterParams();
|
||||
});
|
||||
toggleLoading(false);
|
||||
}
|
||||
},
|
||||
@ -131,18 +112,51 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspaceSlug || !globalViewId) return null;
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.GLOBAL}>
|
||||
<WorkspaceLevelWorkItemFiltersHOC
|
||||
enableSaveView
|
||||
saveViewOptions={{
|
||||
label: "Save as",
|
||||
}}
|
||||
enableUpdateView
|
||||
entityId={globalViewId}
|
||||
entityType={EIssuesStoreType.GLOBAL}
|
||||
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.filters}
|
||||
initialWorkItemFilters={initialWorkItemFilters}
|
||||
updateFilters={updateFilterExpression.bind(updateFilterExpression, workspaceSlug, globalViewId)}
|
||||
workspaceSlug={workspaceSlug}
|
||||
>
|
||||
{({ filter: globalWorkItemsFilter }) => (
|
||||
<div className="h-full overflow-hidden bg-custom-background-100">
|
||||
<div className="flex h-full w-full flex-col border-b border-custom-border-300">
|
||||
{globalWorkItemsFilter && (
|
||||
<WorkItemFiltersRow
|
||||
filter={globalWorkItemsFilter}
|
||||
trackerElements={{
|
||||
saveView: GLOBAL_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<WorkspaceActiveLayout
|
||||
activeLayout={activeLayout}
|
||||
isDefaultView={isDefaultView}
|
||||
isLoading={isLoading}
|
||||
toggleLoading={toggleLoading}
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
globalViewId={globalViewId?.toString()}
|
||||
workspaceSlug={workspaceSlug}
|
||||
globalViewId={globalViewId}
|
||||
routeFilters={routeFilters}
|
||||
fetchNextPages={fetchNextPages}
|
||||
globalViewsLoading={globalViewsLoading}
|
||||
issuesLoading={issuesLoading}
|
||||
/>
|
||||
</div>
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</div>
|
||||
)}
|
||||
</WorkspaceLevelWorkItemFiltersHOC>
|
||||
</IssuesStoreContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,23 +1,30 @@
|
||||
import React, { Fragment } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
|
||||
// hooks
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
|
||||
// local imports
|
||||
import { IssuePeekOverview } from "../../peek-overview";
|
||||
import { ArchivedIssueAppliedFiltersRoot } from "../filters";
|
||||
import { ArchivedIssueListLayout } from "../list/roots/archived-issue-root";
|
||||
|
||||
export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
|
||||
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
|
||||
// hooks
|
||||
const { issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
// derived values
|
||||
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId ? `ARCHIVED_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
|
||||
@ -29,11 +36,9 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const issueFilters = issuesFilter?.getIssueFilters(projectId?.toString());
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
if (isLoading && !issueFilters)
|
||||
if (isLoading && !workItemFilters)
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<LogoSpinner />
|
||||
@ -42,13 +47,25 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
|
||||
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.ARCHIVED}>
|
||||
<ArchivedIssueAppliedFiltersRoot />
|
||||
<Fragment>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
entityType={EIssuesStoreType.ARCHIVED}
|
||||
entityId={projectId?.toString()}
|
||||
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.archived_issues.filters}
|
||||
initialWorkItemFilters={workItemFilters}
|
||||
updateFilters={issuesFilter?.updateFilterExpression.bind(issuesFilter, workspaceSlug, projectId)}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
>
|
||||
{({ filter: archivedWorkItemsFilter }) => (
|
||||
<>
|
||||
{archivedWorkItemsFilter && <WorkItemFiltersRow filter={archivedWorkItemsFilter} />}
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
<ArchivedIssueListLayout />
|
||||
</div>
|
||||
<IssuePeekOverview />
|
||||
</Fragment>
|
||||
</>
|
||||
)}
|
||||
</ProjectLevelWorkItemFiltersHOC>
|
||||
</IssuesStoreContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
@ -4,19 +4,21 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane constants
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { TransferIssues } from "@/components/cycles/transfer-issues";
|
||||
import { TransferIssuesModal } from "@/components/cycles/transfer-issues-modal";
|
||||
// hooks
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
|
||||
// local imports
|
||||
import { IssuePeekOverview } from "../../peek-overview";
|
||||
import { CycleCalendarLayout } from "../calendar/roots/cycle-root";
|
||||
import { CycleAppliedFiltersRoot } from "../filters";
|
||||
import { BaseGanttRoot } from "../gantt";
|
||||
import { CycleKanBanLayout } from "../kanban/roots/cycle-root";
|
||||
import { CycleListLayout } from "../list/roots/cycle-root";
|
||||
@ -44,29 +46,30 @@ const CycleIssueLayout = (props: {
|
||||
};
|
||||
|
||||
export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
const { workspaceSlug, projectId, cycleId } = useParams();
|
||||
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, cycleId: routerCycleId } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
|
||||
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
|
||||
const cycleId = routerCycleId ? routerCycleId.toString() : undefined;
|
||||
// store hooks
|
||||
const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { getCycleById } = useCycle();
|
||||
// state
|
||||
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
|
||||
// derived values
|
||||
const workItemFilters = cycleId ? issuesFilter?.getIssueFilters(cycleId) : undefined;
|
||||
const activeLayout = workItemFilters?.displayFilters?.layout;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId && cycleId
|
||||
? `CYCLE_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}_${cycleId.toString()}`
|
||||
: null,
|
||||
workspaceSlug && projectId && cycleId ? `CYCLE_ISSUES_${workspaceSlug}_${projectId}_${cycleId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId && cycleId) {
|
||||
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), cycleId.toString());
|
||||
await issuesFilter?.fetchFilters(workspaceSlug, projectId, cycleId);
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const issueFilters = issuesFilter?.getIssueFilters(cycleId?.toString());
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId) : undefined;
|
||||
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase() ?? "draft";
|
||||
const isCompletedCycle = cycleStatus === "completed";
|
||||
const isProgressSnapshotEmpty = isEmpty(cycleDetails?.progress_snapshot);
|
||||
@ -77,7 +80,7 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
|
||||
if (!workspaceSlug || !projectId || !cycleId) return <></>;
|
||||
|
||||
if (isLoading && !issueFilters)
|
||||
if (isLoading && !workItemFilters)
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<LogoSpinner />
|
||||
@ -86,9 +89,21 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.CYCLE}>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
enableSaveView
|
||||
entityType={EIssuesStoreType.CYCLE}
|
||||
entityId={cycleId}
|
||||
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.filters}
|
||||
initialWorkItemFilters={workItemFilters}
|
||||
updateFilters={issuesFilter?.updateFilterExpression.bind(issuesFilter, workspaceSlug, projectId, cycleId)}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
>
|
||||
{({ filter: cycleWorkItemsFilter }) => (
|
||||
<>
|
||||
<TransferIssuesModal
|
||||
handleClose={() => setTransferIssuesModal(false)}
|
||||
cycleId={cycleId.toString()}
|
||||
cycleId={cycleId}
|
||||
isOpen={transferIssuesModal}
|
||||
/>
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
@ -99,18 +114,23 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
disabled={!isEmpty(cycleDetails?.progress_snapshot)}
|
||||
/>
|
||||
)}
|
||||
<CycleAppliedFiltersRoot />
|
||||
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<CycleIssueLayout
|
||||
activeLayout={activeLayout}
|
||||
cycleId={cycleId?.toString()}
|
||||
isCompletedCycle={isCompletedCycle}
|
||||
{cycleWorkItemsFilter && (
|
||||
<WorkItemFiltersRow
|
||||
filter={cycleWorkItemsFilter}
|
||||
trackerElements={{
|
||||
saveView: PROJECT_VIEW_TRACKER_ELEMENTS.CYCLE_HEADER_SAVE_AS_VIEW_BUTTON,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<CycleIssueLayout activeLayout={activeLayout} cycleId={cycleId} isCompletedCycle={isCompletedCycle} />
|
||||
</div>
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ProjectLevelWorkItemFiltersHOC>
|
||||
</IssuesStoreContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
@ -3,17 +3,19 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
import { Row, ERowVariant } from "@plane/ui";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
// hooks
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
|
||||
// local imports
|
||||
import { IssuePeekOverview } from "../../peek-overview";
|
||||
import { ModuleCalendarLayout } from "../calendar/roots/module-root";
|
||||
import { ModuleAppliedFiltersRoot } from "../filters";
|
||||
import { BaseGanttRoot } from "../gantt";
|
||||
import { ModuleKanBanLayout } from "../kanban/roots/module-root";
|
||||
import { ModuleListLayout } from "../list/roots/module-root";
|
||||
@ -38,9 +40,15 @@ const ModuleIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undefined;
|
||||
|
||||
export const ModuleLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId, moduleId } = useParams();
|
||||
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, moduleId: routerModuleId } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
|
||||
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
|
||||
const moduleId = routerModuleId ? routerModuleId.toString() : undefined;
|
||||
// hooks
|
||||
const { issuesFilter } = useIssues(EIssuesStoreType.MODULE);
|
||||
// derived values
|
||||
const workItemFilters = moduleId ? issuesFilter?.getIssueFilters(moduleId) : undefined;
|
||||
const activeLayout = workItemFilters?.displayFilters?.layout || undefined;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId && moduleId
|
||||
@ -54,29 +62,45 @@ export const ModuleLayoutRoot: React.FC = observer(() => {
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const issueFilters = issuesFilter?.getIssueFilters(moduleId?.toString());
|
||||
|
||||
if (!workspaceSlug || !projectId || !moduleId) return <></>;
|
||||
|
||||
if (isLoading && !issueFilters)
|
||||
if (isLoading && !workItemFilters)
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout || undefined;
|
||||
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.MODULE}>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
enableSaveView
|
||||
entityType={EIssuesStoreType.MODULE}
|
||||
entityId={moduleId}
|
||||
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.filters}
|
||||
initialWorkItemFilters={workItemFilters}
|
||||
updateFilters={issuesFilter?.updateFilterExpression.bind(issuesFilter, workspaceSlug, projectId, moduleId)}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
>
|
||||
{({ filter: moduleWorkItemsFilter }) => (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<ModuleAppliedFiltersRoot />
|
||||
{moduleWorkItemsFilter && (
|
||||
<WorkItemFiltersRow
|
||||
filter={moduleWorkItemsFilter}
|
||||
trackerElements={{
|
||||
saveView: PROJECT_VIEW_TRACKER_ELEMENTS.MODULE_HEADER_SAVE_AS_VIEW_BUTTON,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Row variant={ERowVariant.HUGGING} className="h-full w-full overflow-auto">
|
||||
<ModuleIssueLayout activeLayout={activeLayout} moduleId={moduleId?.toString()} />
|
||||
<ModuleIssueLayout activeLayout={activeLayout} moduleId={moduleId} />
|
||||
</Row>
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</div>
|
||||
)}
|
||||
</ProjectLevelWorkItemFiltersHOC>
|
||||
</IssuesStoreContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,21 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { FC, Fragment } from "react";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane constants
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
// components
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
|
||||
// local imports
|
||||
import { IssuePeekOverview } from "../../peek-overview";
|
||||
import { CalendarLayout } from "../calendar/roots/project-root";
|
||||
import { ProjectAppliedFiltersRoot } from "../filters";
|
||||
import { BaseGanttRoot } from "../gantt";
|
||||
import { KanBanLayout } from "../kanban/roots/project-root";
|
||||
import { ListLayout } from "../list/roots/project-root";
|
||||
@ -40,26 +42,28 @@ const ProjectIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undefined
|
||||
|
||||
export const ProjectLayoutRoot: FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined;
|
||||
const projectId = routerProjectId ? routerProjectId.toString() : undefined;
|
||||
// hooks
|
||||
const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
|
||||
// derived values
|
||||
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
|
||||
const activeLayout = workItemFilters?.displayFilters?.layout;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString());
|
||||
await issuesFilter?.fetchFilters(workspaceSlug, projectId);
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const issueFilters = issuesFilter?.getIssueFilters(projectId?.toString());
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
if (isLoading && !issueFilters)
|
||||
if (isLoading && !workItemFilters)
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<LogoSpinner />
|
||||
@ -68,8 +72,26 @@ export const ProjectLayoutRoot: FC = observer(() => {
|
||||
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.PROJECT}>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
enableSaveView
|
||||
entityType={EIssuesStoreType.PROJECT}
|
||||
entityId={projectId}
|
||||
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.filters}
|
||||
initialWorkItemFilters={workItemFilters}
|
||||
updateFilters={issuesFilter?.updateFilterExpression.bind(issuesFilter, workspaceSlug, projectId)}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
>
|
||||
{({ filter: projectWorkItemsFilter }) => (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<ProjectAppliedFiltersRoot />
|
||||
{projectWorkItemsFilter && (
|
||||
<WorkItemFiltersRow
|
||||
filter={projectWorkItemsFilter}
|
||||
trackerElements={{
|
||||
saveView: PROJECT_VIEW_TRACKER_ELEMENTS.PROJECT_HEADER_SAVE_AS_VIEW_BUTTON,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="relative h-full w-full overflow-auto bg-custom-background-90">
|
||||
{/* mutation loader */}
|
||||
{issues?.getIssueLoader() === "mutation" && (
|
||||
@ -79,10 +101,11 @@ export const ProjectLayoutRoot: FC = observer(() => {
|
||||
)}
|
||||
<ProjectIssueLayout activeLayout={activeLayout} />
|
||||
</div>
|
||||
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</div>
|
||||
)}
|
||||
</ProjectLevelWorkItemFiltersHOC>
|
||||
</IssuesStoreContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,23 +1,25 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane constants
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
// hooks
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useProjectView } from "@/hooks/store/use-project-view";
|
||||
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
|
||||
// local imports
|
||||
import { IssuePeekOverview } from "../../peek-overview";
|
||||
import { ProjectViewCalendarLayout } from "../calendar/roots/project-view-root";
|
||||
import { ProjectViewAppliedFiltersRoot } from "../filters";
|
||||
import { BaseGanttRoot } from "../gantt";
|
||||
import { ProjectViewKanBanLayout } from "../kanban/roots/project-view-root";
|
||||
import { ProjectViewListLayout } from "../list/roots/project-view-root";
|
||||
import { ProjectViewSpreadsheetLayout } from "../spreadsheet/roots/project-view-root";
|
||||
// types
|
||||
|
||||
const ProjectViewIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undefined; viewId: string }) => {
|
||||
switch (props.activeLayout) {
|
||||
@ -38,26 +40,47 @@ const ProjectViewIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undef
|
||||
|
||||
export const ProjectViewLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId, viewId } = useParams();
|
||||
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId, viewId: routerViewId } = useParams();
|
||||
const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug?.toString() : undefined;
|
||||
const projectId = routerProjectId ? routerProjectId?.toString() : undefined;
|
||||
const viewId = routerViewId ? routerViewId?.toString() : undefined;
|
||||
// hooks
|
||||
const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW);
|
||||
const { getViewById } = useProjectView();
|
||||
// derived values
|
||||
const projectView = viewId ? getViewById(viewId) : undefined;
|
||||
const workItemFilters = viewId ? issuesFilter?.getIssueFilters(viewId) : undefined;
|
||||
const activeLayout = workItemFilters?.displayFilters?.layout;
|
||||
const initialWorkItemFilters = projectView
|
||||
? {
|
||||
displayFilters: workItemFilters?.displayFilters,
|
||||
displayProperties: workItemFilters?.displayProperties,
|
||||
kanbanFilters: workItemFilters?.kanbanFilters,
|
||||
richFilters: projectView.rich_filters,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId && viewId ? `PROJECT_VIEW_ISSUES_${workspaceSlug}_${projectId}_${viewId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId && viewId) {
|
||||
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), viewId.toString());
|
||||
await issuesFilter?.fetchFilters(workspaceSlug, projectId, viewId);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const issueFilters = issuesFilter?.getIssueFilters(viewId?.toString());
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (workspaceSlug && viewId) {
|
||||
issuesFilter?.resetFilters(workspaceSlug, viewId);
|
||||
}
|
||||
},
|
||||
[issuesFilter, workspaceSlug, viewId]
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId || !viewId) return <></>;
|
||||
|
||||
if (isLoading && !issueFilters) {
|
||||
if (isLoading && !workItemFilters) {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
@ -67,15 +90,38 @@ export const ProjectViewLayoutRoot: React.FC = observer(() => {
|
||||
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.PROJECT_VIEW}>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
enableSaveView
|
||||
saveViewOptions={{
|
||||
label: "Save as",
|
||||
}}
|
||||
enableUpdateView
|
||||
entityId={viewId}
|
||||
entityType={EIssuesStoreType.PROJECT_VIEW}
|
||||
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.filters}
|
||||
initialWorkItemFilters={initialWorkItemFilters}
|
||||
updateFilters={issuesFilter?.updateFilterExpression.bind(issuesFilter, workspaceSlug, projectId, viewId)}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
>
|
||||
{({ filter: projectViewWorkItemsFilter }) => (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<ProjectViewAppliedFiltersRoot />
|
||||
{projectViewWorkItemsFilter && (
|
||||
<WorkItemFiltersRow
|
||||
filter={projectViewWorkItemsFilter}
|
||||
trackerElements={{
|
||||
saveView: PROJECT_VIEW_TRACKER_ELEMENTS.HEADER_SAVE_VIEW_BUTTON,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
<ProjectViewIssueLayout activeLayout={activeLayout} viewId={viewId.toString()} />
|
||||
</div>
|
||||
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</div>
|
||||
)}
|
||||
</ProjectLevelWorkItemFiltersHOC>
|
||||
</IssuesStoreContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useState } from "react";
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
import { Button } from "@plane/ui";
|
||||
// components
|
||||
import { CreateUpdateProjectViewModal } from "@/components/views/modal";
|
||||
|
||||
interface ISaveFilterView {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
filterParams: {
|
||||
filters: IIssueFilterOptions;
|
||||
display_filters?: IIssueDisplayFilterOptions;
|
||||
display_properties?: IIssueDisplayProperties;
|
||||
};
|
||||
trackerElement: string;
|
||||
}
|
||||
|
||||
export const SaveFilterView: FC<ISaveFilterView> = (props) => {
|
||||
const { workspaceSlug, projectId, filterParams, trackerElement } = props;
|
||||
|
||||
const [viewModal, setViewModal] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CreateUpdateProjectViewModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
preLoadedData={{ ...filterParams }}
|
||||
isOpen={viewModal}
|
||||
onClose={() => setViewModal(false)}
|
||||
/>
|
||||
|
||||
<Button size="sm" onClick={() => setViewModal(true)} data-ph-element={trackerElement}>
|
||||
Save View
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -25,7 +25,7 @@ export const SpreadsheetLabelColumn: React.FC<Props> = observer((props: Props) =
|
||||
<div className="h-11 border-b-[0.5px] border-custom-border-200 w-full">
|
||||
<IssuePropertyLabels
|
||||
projectId={issue.project_id ?? null}
|
||||
value={issue.label_ids}
|
||||
value={issue.label_ids || []}
|
||||
defaultOptions={defaultLabelOptions}
|
||||
onChange={(data) => onChange(issue, { label_ids: data }, { changed_property: "labels", change_details: data })}
|
||||
className="h-full w-full "
|
||||
|
||||
@ -9,11 +9,9 @@ import { SpreadsheetLayoutLoader } from "@/components/ui/loader/layouts/spreadsh
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
|
||||
// store
|
||||
import { IssuePeekOverview } from "../../../peek-overview";
|
||||
import { IssueLayoutHOC } from "../../issue-layout-HOC";
|
||||
import { TRenderQuickActions } from "../../list/list-view-types";
|
||||
import { SpreadsheetView } from "../spreadsheet-view";
|
||||
@ -108,7 +106,6 @@ export const WorkspaceSpreadsheetRoot: React.FC<Props> = observer((props: Props)
|
||||
|
||||
// Render spreadsheet
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.GLOBAL}>
|
||||
<IssueLayoutHOC layout={EIssueLayoutTypes.SPREADSHEET}>
|
||||
<SpreadsheetView
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
@ -122,9 +119,6 @@ export const WorkspaceSpreadsheetRoot: React.FC<Props> = observer((props: Props)
|
||||
loadMoreIssues={fetchNextPages}
|
||||
isWorkspaceLevel
|
||||
/>
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</IssueLayoutHOC>
|
||||
</IssuesStoreContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
@ -4,7 +4,6 @@ import { CSSProperties, FC } from "react";
|
||||
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import clone from "lodash/clone";
|
||||
import concat from "lodash/concat";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import isNil from "lodash/isNil";
|
||||
import pull from "lodash/pull";
|
||||
import uniq from "lodash/uniq";
|
||||
@ -24,9 +23,7 @@ import {
|
||||
TIssueGroupByOptions,
|
||||
IIssueFilterOptions,
|
||||
IIssueFilters,
|
||||
IProjectView,
|
||||
TGroupedIssues,
|
||||
IWorkspaceView,
|
||||
IIssueDisplayFilterOptions,
|
||||
TGetColumns,
|
||||
} from "@plane/types";
|
||||
@ -593,27 +590,6 @@ export const handleGroupDragDrop = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This Method compares filters and returns a boolean based on which and updateView button is shown
|
||||
* @param appliedFilters
|
||||
* @param issueFilters
|
||||
* @param viewDetails
|
||||
* @returns
|
||||
*/
|
||||
export const getAreFiltersEqual = (
|
||||
appliedFilters: IIssueFilterOptions | undefined,
|
||||
issueFilters: IIssueFilters | undefined,
|
||||
viewDetails: IProjectView | IWorkspaceView | null
|
||||
) => {
|
||||
if (isNil(appliedFilters) || isNil(issueFilters) || isNil(viewDetails)) return true;
|
||||
|
||||
return (
|
||||
isEqual(appliedFilters, viewDetails.filters) &&
|
||||
isEqual(issueFilters.displayFilters, viewDetails.display_filters) &&
|
||||
isEqual(removeNillKeys(issueFilters.displayProperties), removeNillKeys(viewDetails.display_properties))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* method that removes Null or undefined Keys from object
|
||||
* @param obj
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { FC, Fragment, useCallback, useMemo, useState } from "react";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { FC, Fragment, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { AlertCircle, ChevronUp, ChevronDown } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import { EIssueFilterType, EEstimateSystem } from "@plane/constants";
|
||||
import { EEstimateSystem } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EIssuesStoreType, IIssueFilterOptions, TModulePlotType } from "@plane/types";
|
||||
import { EIssuesStoreType, TModulePlotType } from "@plane/types";
|
||||
import { CustomSelect, Spinner } from "@plane/ui";
|
||||
// components
|
||||
// constants
|
||||
@ -18,8 +17,8 @@ import ProgressChart from "@/components/core/sidebar/progress-chart";
|
||||
import { ModuleProgressStats } from "@/components/modules";
|
||||
// hooks
|
||||
import { useProjectEstimates } from "@/hooks/store/estimates";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
|
||||
// plane web constants
|
||||
type TModuleAnalyticsProgress = {
|
||||
workspaceSlug: string;
|
||||
@ -38,31 +37,30 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
|
||||
// router
|
||||
const searchParams = useSearchParams();
|
||||
const peekModule = searchParams.get("peekModule") || undefined;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
||||
const { getPlotTypeByModuleId, setPlotType, getModuleById, fetchModuleDetails, fetchArchivedModuleDetails } =
|
||||
useModule();
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.MODULE);
|
||||
const { getFilter, updateFilterValueFromSidebar } = useWorkItemFilters();
|
||||
// state
|
||||
const [loader, setLoader] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// derived values
|
||||
const moduleFilter = getFilter(EIssuesStoreType.MODULE, moduleId);
|
||||
const selectedAssignees = moduleFilter?.findFirstConditionByPropertyAndOperator("assignee_id", "in");
|
||||
const selectedLabels = moduleFilter?.findFirstConditionByPropertyAndOperator("label_id", "in");
|
||||
const selectedStateGroups = moduleFilter?.findFirstConditionByPropertyAndOperator("state_group", "in");
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
const plotType: TModulePlotType = getPlotTypeByModuleId(moduleId);
|
||||
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
|
||||
const estimateDetails =
|
||||
isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
|
||||
const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS;
|
||||
|
||||
const completedIssues = moduleDetails?.completed_issues || 0;
|
||||
const totalIssues = moduleDetails?.total_issues || 0;
|
||||
const completedEstimatePoints = moduleDetails?.completed_estimate_points || 0;
|
||||
const totalEstimatePoints = moduleDetails?.total_estimate_points || 0;
|
||||
|
||||
const progressHeaderPercentage = moduleDetails
|
||||
? plotType === "points"
|
||||
? completedEstimatePoints != 0 && totalEstimatePoints != 0
|
||||
@ -72,11 +70,9 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
|
||||
? Math.round((completedIssues / totalIssues) * 100)
|
||||
: 0
|
||||
: 0;
|
||||
|
||||
const chartDistributionData =
|
||||
plotType === "points" ? moduleDetails?.estimate_distribution : moduleDetails?.distribution || undefined;
|
||||
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
|
||||
|
||||
const groupedIssues = useMemo(
|
||||
() => ({
|
||||
backlog: plotType === "points" ? moduleDetails?.backlog_estimate_points || 0 : moduleDetails?.backlog_issues || 0,
|
||||
@ -90,7 +86,6 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
|
||||
}),
|
||||
[plotType, moduleDetails]
|
||||
);
|
||||
|
||||
const moduleStartDate = getDate(moduleDetails?.start_date);
|
||||
const moduleEndDate = getDate(moduleDetails?.target_date);
|
||||
const isModuleStartDateValid = moduleStartDate && moduleStartDate <= new Date();
|
||||
@ -116,37 +111,6 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
let newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (key === "state") {
|
||||
if (isEqual(newValues, value)) newValues = [];
|
||||
else newValues = value;
|
||||
} else {
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
moduleId
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, moduleId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
if (!moduleDetails) return <></>;
|
||||
return (
|
||||
<div className="border-t border-custom-border-200 space-y-4 py-4 px-3">
|
||||
@ -234,17 +198,25 @@ export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((p
|
||||
{chartDistributionData && (
|
||||
<div className="w-full border-t border-custom-border-200 pt-5">
|
||||
<ModuleProgressStats
|
||||
moduleId={moduleId}
|
||||
plotType={plotType}
|
||||
distribution={chartDistributionData}
|
||||
groupedIssues={groupedIssues}
|
||||
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
|
||||
isEditable={Boolean(!peekModule)}
|
||||
size="xs"
|
||||
roundedTab={false}
|
||||
handleFiltersUpdate={updateFilterValueFromSidebar.bind(
|
||||
updateFilterValueFromSidebar,
|
||||
EIssuesStoreType.MODULE,
|
||||
moduleId
|
||||
)}
|
||||
isEditable={Boolean(!peekModule) && moduleFilter !== undefined}
|
||||
moduleId={moduleId}
|
||||
noBackground={false}
|
||||
filters={issueFilters}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
plotType={plotType}
|
||||
roundedTab={false}
|
||||
selectedFilters={{
|
||||
assignees: selectedAssignees,
|
||||
labels: selectedLabels,
|
||||
stateGroups: selectedStateGroups,
|
||||
}}
|
||||
size="xs"
|
||||
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -2,278 +2,65 @@
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { StateGroupIcon } from "@plane/propel/icons";
|
||||
import {
|
||||
IIssueFilterOptions,
|
||||
IIssueFilters,
|
||||
TModuleDistribution,
|
||||
TModuleEstimateDistribution,
|
||||
TModulePlotType,
|
||||
TStateGroups,
|
||||
} from "@plane/types";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { cn, getFileURL } from "@plane/utils";
|
||||
import { TWorkItemFilterCondition } from "@plane/shared-state";
|
||||
import { TModuleDistribution, TModuleEstimateDistribution, TModulePlotType } from "@plane/types";
|
||||
import { cn, toFilterArray } from "@plane/utils";
|
||||
// components
|
||||
import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats";
|
||||
// helpers
|
||||
import { AssigneeStatComponent, TAssigneeData } from "@/components/core/sidebar/progress-stats/assignee";
|
||||
import { LabelStatComponent, TLabelData } from "@/components/core/sidebar/progress-stats/label";
|
||||
import {
|
||||
createFilterUpdateHandler,
|
||||
PROGRESS_STATS,
|
||||
TSelectedFilterProgressStats,
|
||||
} from "@/components/core/sidebar/progress-stats/shared";
|
||||
import { StateGroupStatComponent, TStateGroupData } from "@/components/core/sidebar/progress-stats/state_group";
|
||||
// hooks
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// public
|
||||
import emptyLabel from "@/public/empty-state/empty_label.svg";
|
||||
import emptyMembers from "@/public/empty-state/empty_members.svg";
|
||||
|
||||
// assignee types
|
||||
type TAssigneeData = {
|
||||
id: string | undefined;
|
||||
title: string | undefined;
|
||||
avatar_url: string | undefined;
|
||||
completed: number;
|
||||
total: number;
|
||||
}[];
|
||||
|
||||
type TAssigneeStatComponent = {
|
||||
distribution: TAssigneeData;
|
||||
isEditable?: boolean;
|
||||
filters?: IIssueFilters | undefined;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
};
|
||||
|
||||
// labelTypes
|
||||
type TLabelData = {
|
||||
id: string | undefined;
|
||||
title: string | undefined;
|
||||
color: string | undefined;
|
||||
completed: number;
|
||||
total: number;
|
||||
}[];
|
||||
|
||||
type TLabelStatComponent = {
|
||||
distribution: TLabelData;
|
||||
isEditable?: boolean;
|
||||
filters?: IIssueFilters | undefined;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
};
|
||||
|
||||
// stateTypes
|
||||
type TStateData = {
|
||||
state: string | undefined;
|
||||
completed: number;
|
||||
total: number;
|
||||
}[];
|
||||
|
||||
type TStateStatComponent = {
|
||||
distribution: TStateData;
|
||||
totalIssuesCount: number;
|
||||
isEditable?: boolean;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
};
|
||||
|
||||
export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => {
|
||||
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
{distribution && distribution.length > 0 ? (
|
||||
distribution.map((assignee, index) => {
|
||||
if (assignee?.id)
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={assignee?.id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={assignee?.title ?? undefined} src={getFileURL(assignee?.avatar_url ?? "")} />
|
||||
<span>{assignee?.title ?? ""}</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee?.completed ?? 0}
|
||||
total={assignee?.total ?? 0}
|
||||
{...(isEditable && {
|
||||
onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""),
|
||||
selected: filters?.filters?.assignees?.includes(assignee.id ?? ""),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`unassigned-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
|
||||
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
|
||||
</div>
|
||||
<span>{t("no_assignee")}</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee?.completed ?? 0}
|
||||
total={assignee?.total ?? 0}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
||||
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
|
||||
</div>
|
||||
<h6 className="text-base text-custom-text-300">{t("no_assignees_yet")}</h6>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const LabelStatComponent = observer((props: TLabelStatComponent) => {
|
||||
const { distribution, isEditable, filters, handleFiltersUpdate } = props;
|
||||
return (
|
||||
<div>
|
||||
{distribution && distribution.length > 0 ? (
|
||||
distribution.map((label, index) => {
|
||||
if (label.id) {
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={label.id}
|
||||
title={
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "transparent",
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-ellipsis truncate">{label.title ?? "No labels"}</p>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed}
|
||||
total={label.total}
|
||||
{...(isEditable && {
|
||||
onClick: () => handleFiltersUpdate("labels", label.id ?? ""),
|
||||
selected: filters?.filters?.labels?.includes(label.id ?? `no-label-${index}`),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`no-label-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "transparent",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">{label.title ?? "No labels"}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed}
|
||||
total={label.total}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-custom-background-80">
|
||||
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
|
||||
</div>
|
||||
<h6 className="text-base text-custom-text-300">No labels yet</h6>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const StateStatComponent = observer((props: TStateStatComponent) => {
|
||||
const { distribution, isEditable, totalIssuesCount, handleFiltersUpdate } = props;
|
||||
// hooks
|
||||
const { groupedProjectStates } = useProjectState();
|
||||
// derived values
|
||||
const getStateGroupState = (stateGroup: string) => {
|
||||
const stateGroupStates = groupedProjectStates?.[stateGroup];
|
||||
const stateGroupStatesId = stateGroupStates?.map((state) => state.id);
|
||||
return stateGroupStatesId;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{distribution.map((group, index) => (
|
||||
<SingleProgressStats
|
||||
key={index}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<StateGroupIcon stateGroup={group.state as TStateGroups} />
|
||||
<span className="text-xs capitalize">{group.state}</span>
|
||||
</div>
|
||||
}
|
||||
completed={group.completed}
|
||||
total={totalIssuesCount}
|
||||
{...(isEditable && {
|
||||
onClick: () => group.state && handleFiltersUpdate("state", getStateGroupState(group.state) ?? []),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const progressStats = [
|
||||
{
|
||||
key: "stat-assignees",
|
||||
title: "Assignees",
|
||||
},
|
||||
{
|
||||
key: "stat-labels",
|
||||
title: "Labels",
|
||||
},
|
||||
{
|
||||
key: "stat-states",
|
||||
title: "States",
|
||||
},
|
||||
];
|
||||
|
||||
type TModuleProgressStats = {
|
||||
moduleId: string;
|
||||
plotType: TModulePlotType;
|
||||
distribution: TModuleDistribution | TModuleEstimateDistribution | undefined;
|
||||
groupedIssues: Record<string, number>;
|
||||
totalIssuesCount: number;
|
||||
handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void;
|
||||
isEditable?: boolean;
|
||||
filters?: IIssueFilters | undefined;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
size?: "xs" | "sm";
|
||||
roundedTab?: boolean;
|
||||
moduleId: string;
|
||||
noBackground?: boolean;
|
||||
plotType: TModulePlotType;
|
||||
roundedTab?: boolean;
|
||||
selectedFilters: TSelectedFilterProgressStats;
|
||||
size?: "xs" | "sm";
|
||||
totalIssuesCount: number;
|
||||
};
|
||||
|
||||
export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) => {
|
||||
const {
|
||||
moduleId,
|
||||
plotType,
|
||||
distribution,
|
||||
groupedIssues,
|
||||
totalIssuesCount,
|
||||
isEditable = false,
|
||||
filters,
|
||||
handleFiltersUpdate,
|
||||
size = "sm",
|
||||
roundedTab = false,
|
||||
isEditable = false,
|
||||
moduleId,
|
||||
noBackground = false,
|
||||
plotType,
|
||||
roundedTab = false,
|
||||
selectedFilters,
|
||||
size = "sm",
|
||||
totalIssuesCount,
|
||||
} = props;
|
||||
// plane imports
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { storedValue: currentTab, setValue: setModuleTab } = useLocalStorage(
|
||||
`module-analytics-tab-${moduleId}`,
|
||||
"stat-assignees"
|
||||
);
|
||||
// derived values
|
||||
const currentTabIndex = (tab: string): number => progressStats.findIndex((stat) => stat.key === tab);
|
||||
|
||||
const currentTabIndex = (tab: string): number => PROGRESS_STATS.findIndex((stat) => stat.key === tab);
|
||||
const currentDistribution = distribution as TModuleDistribution;
|
||||
const currentEstimateDistribution = distribution as TModuleEstimateDistribution;
|
||||
const selectedAssigneeIds = toFilterArray(selectedFilters?.assignees?.value || []) as string[];
|
||||
const selectedLabelIds = toFilterArray(selectedFilters?.labels?.value || []) as string[];
|
||||
const selectedStateGroups = toFilterArray(selectedFilters?.stateGroups?.value || []) as string[];
|
||||
|
||||
const distributionAssigneeData: TAssigneeData =
|
||||
plotType === "burndown"
|
||||
@ -309,12 +96,24 @@ export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) =>
|
||||
total: label.total_estimates,
|
||||
}));
|
||||
|
||||
const distributionStateData: TStateData = Object.keys(groupedIssues || {}).map((state) => ({
|
||||
const distributionStateData: TStateGroupData = Object.keys(groupedIssues || {}).map((state) => ({
|
||||
state: state,
|
||||
completed: groupedIssues?.[state] || 0,
|
||||
total: totalIssuesCount || 0,
|
||||
}));
|
||||
|
||||
const handleAssigneeFiltersUpdate = createFilterUpdateHandler(
|
||||
"assignee_id",
|
||||
selectedAssigneeIds,
|
||||
handleFiltersUpdate
|
||||
);
|
||||
const handleLabelFiltersUpdate = createFilterUpdateHandler("label_id", selectedLabelIds, handleFiltersUpdate);
|
||||
const handleStateGroupFiltersUpdate = createFilterUpdateHandler(
|
||||
"state_group",
|
||||
selectedStateGroups,
|
||||
handleFiltersUpdate
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tab.Group defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
|
||||
@ -327,7 +126,7 @@ export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) =>
|
||||
size === "xs" ? `text-xs` : `text-sm`
|
||||
)}
|
||||
>
|
||||
{progressStats.map((stat) => (
|
||||
{PROGRESS_STATS.map((stat) => (
|
||||
<Tab
|
||||
className={cn(
|
||||
`p-1 w-full text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all`,
|
||||
@ -339,7 +138,7 @@ export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) =>
|
||||
key={stat.key}
|
||||
onClick={() => setModuleTab(stat.key)}
|
||||
>
|
||||
{stat.title}
|
||||
{t(stat.i18n_title)}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
@ -347,25 +146,26 @@ export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) =>
|
||||
<Tab.Panel key={"stat-assignees"}>
|
||||
<AssigneeStatComponent
|
||||
distribution={distributionAssigneeData}
|
||||
handleAssigneeFiltersUpdate={handleAssigneeFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
filters={filters}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
selectedAssigneeIds={selectedAssigneeIds}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel key={"stat-labels"}>
|
||||
<LabelStatComponent
|
||||
distribution={distributionLabelData}
|
||||
handleLabelFiltersUpdate={handleLabelFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
filters={filters}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
selectedLabelIds={selectedLabelIds}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel key={"stat-states"}>
|
||||
<StateStatComponent
|
||||
<StateGroupStatComponent
|
||||
distribution={distributionStateData}
|
||||
totalIssuesCount={totalIssuesCount}
|
||||
handleStateGroupFiltersUpdate={handleStateGroupFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
selectedStateGroups={selectedStateGroups}
|
||||
totalIssuesCount={totalIssuesCount}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
|
||||
@ -6,25 +6,11 @@ import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constant
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
EIssueLayoutTypes,
|
||||
} from "@plane/types";
|
||||
import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
|
||||
// components
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
import {
|
||||
DisplayFiltersSelection,
|
||||
FilterSelection,
|
||||
FiltersDropdown,
|
||||
LayoutSelection,
|
||||
} from "@/components/issues/issue-layouts/filters";
|
||||
// helpers
|
||||
import { DisplayFiltersSelection, FiltersDropdown, LayoutSelection } from "@/components/issues/issue-layouts/filters";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
|
||||
export const ProfileIssuesFilter = observer(() => {
|
||||
// i18n
|
||||
@ -35,11 +21,7 @@ export const ProfileIssuesFilter = observer(() => {
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.PROFILE);
|
||||
|
||||
const { workspaceLabels } = useLabel();
|
||||
// derived values
|
||||
const states = undefined;
|
||||
const members = undefined;
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
@ -56,33 +38,6 @@ export const ProfileIssuesFilter = observer(() => {
|
||||
[workspaceSlug, updateFilters, userId]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !userId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
userId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, issueFilters, updateFilters, userId]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !userId) return;
|
||||
@ -118,30 +73,10 @@ export const ProfileIssuesFilter = observer(() => {
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues[activeLayout] : undefined
|
||||
}
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
states={states}
|
||||
labels={workspaceLabels}
|
||||
memberIds={members}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
|
||||
<FiltersDropdown title={t("common.display")} placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues[activeLayout] : undefined
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues.layoutOptions[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
|
||||
@ -3,12 +3,14 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
// components
|
||||
import { ProfileIssuesAppliedFiltersRoot } from "@/components/issues/issue-layouts/filters";
|
||||
import { ProfileIssuesKanBanLayout } from "@/components/issues/issue-layouts/kanban/roots/profile-issues-root";
|
||||
import { ProfileIssuesListLayout } from "@/components/issues/issue-layouts/list/roots/profile-issues-root";
|
||||
import { IssuePeekOverview } from "@/components/issues/peek-overview";
|
||||
import { WorkspaceLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/workspace-level";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
|
||||
// hooks
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
|
||||
@ -19,7 +21,6 @@ type Props = {
|
||||
|
||||
export const ProfileIssuesPage = observer((props: Props) => {
|
||||
const { type } = props;
|
||||
|
||||
const { workspaceSlug, userId } = useParams() as {
|
||||
workspaceSlug: string;
|
||||
userId: string;
|
||||
@ -27,8 +28,10 @@ export const ProfileIssuesPage = observer((props: Props) => {
|
||||
// store hooks
|
||||
const {
|
||||
issues: { setViewId },
|
||||
issuesFilter: { issueFilters, fetchFilters },
|
||||
issuesFilter: { issueFilters, fetchFilters, updateFilterExpression },
|
||||
} = useIssues(EIssuesStoreType.PROFILE);
|
||||
// derived values
|
||||
const activeLayout = issueFilters?.displayFilters?.layout || undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (setViewId) setViewId(type);
|
||||
@ -44,12 +47,20 @@ export const ProfileIssuesPage = observer((props: Props) => {
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout || undefined;
|
||||
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.PROFILE}>
|
||||
<WorkspaceLevelWorkItemFiltersHOC
|
||||
entityId={userId}
|
||||
entityType={EIssuesStoreType.PROFILE}
|
||||
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues.filters}
|
||||
initialWorkItemFilters={issueFilters}
|
||||
updateFilters={updateFilterExpression.bind(updateFilterExpression, workspaceSlug, userId)}
|
||||
workspaceSlug={workspaceSlug}
|
||||
>
|
||||
{({ filter: profileWorkItemsFilter }) => (
|
||||
<>
|
||||
<div className="flex flex-col h-full w-full">
|
||||
<ProfileIssuesAppliedFiltersRoot />
|
||||
{profileWorkItemsFilter && <WorkItemFiltersRow filter={profileWorkItemsFilter} />}
|
||||
<div className="-z-1 relative h-full w-full overflow-auto">
|
||||
{activeLayout === "list" ? (
|
||||
<ProfileIssuesListLayout />
|
||||
@ -60,6 +71,9 @@ export const ProfileIssuesPage = observer((props: Props) => {
|
||||
</div>
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</>
|
||||
)}
|
||||
</WorkspaceLevelWorkItemFiltersHOC>
|
||||
</IssuesStoreContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
@ -119,7 +119,7 @@ export const ProfileSidebar: FC<TProfileSidebar> = observer((props) => {
|
||||
className="h-full w-full rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[52px] w-[52px] items-center justify-center rounded bg-custom-background-90 capitalize text-custom-text-100">
|
||||
<div className="flex h-[52px] w-[52px] items-center justify-center rounded bg-[#028375] capitalize text-white">
|
||||
{userData?.first_name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -4,7 +4,7 @@ import { ListFilter } from "lucide-react";
|
||||
// plane imports
|
||||
import { IFilterInstance } from "@plane/shared-state";
|
||||
import { LOGICAL_OPERATOR, TExternalFilter, TFilterProperty } from "@plane/types";
|
||||
import { CustomSearchSelect, getButtonStyling, TButtonVariant } from "@plane/ui";
|
||||
import { CustomSearchSelect, getButtonStyling, setToast, TButtonVariant, TOAST_TYPE } from "@plane/ui";
|
||||
import { cn, getOperatorForPayload } from "@plane/utils";
|
||||
|
||||
export type TAddFilterButtonProps<P extends TFilterProperty, E extends TExternalFilter> = {
|
||||
@ -15,7 +15,7 @@ export type TAddFilterButtonProps<P extends TFilterProperty, E extends TExternal
|
||||
defaultOpen?: boolean;
|
||||
iconConfig?: {
|
||||
shouldShowIcon: boolean;
|
||||
iconComponent?: React.ReactNode;
|
||||
iconComponent?: React.ElementType;
|
||||
};
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
@ -34,6 +34,8 @@ export const AddFilterButton = observer(
|
||||
iconConfig = { shouldShowIcon: true },
|
||||
isDisabled = false,
|
||||
} = buttonConfig || {};
|
||||
// derived values
|
||||
const FilterIcon = iconConfig.iconComponent || ListFilter;
|
||||
|
||||
// Transform available filter configs to CustomSearchSelect options format
|
||||
const filterOptions = filter.configManager.allAvailableConfigs.map((config) => ({
|
||||
@ -64,7 +66,7 @@ export const AddFilterButton = observer(
|
||||
|
||||
const handleFilterSelect = (property: P) => {
|
||||
const config = filter.configManager.getConfigByProperty(property);
|
||||
if (config && config.firstOperator) {
|
||||
if (config?.firstOperator) {
|
||||
const { operator, isNegation } = getOperatorForPayload(config.firstOperator);
|
||||
filter.addCondition(
|
||||
LOGICAL_OPERATOR.AND,
|
||||
@ -76,6 +78,12 @@ export const AddFilterButton = observer(
|
||||
isNegation
|
||||
);
|
||||
onFilterSelect?.(property);
|
||||
} else {
|
||||
setToast({
|
||||
title: "Filter configuration error",
|
||||
message: "This filter is not properly configured and cannot be applied",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -91,11 +99,10 @@ export const AddFilterButton = observer(
|
||||
maxHeight="full"
|
||||
placement="bottom-start"
|
||||
disabled={isDisabled}
|
||||
customButtonClassName={cn(getButtonStyling(variant, "sm"), className)}
|
||||
customButtonClassName={cn(getButtonStyling(variant, "sm"), "py-[5px]", className)}
|
||||
customButton={
|
||||
<div className="flex items-center gap-1">
|
||||
{iconConfig.shouldShowIcon &&
|
||||
(iconConfig.iconComponent || <ListFilter className="size-4 text-custom-text-200" />)}
|
||||
{iconConfig.shouldShowIcon && <FilterIcon className="size-4 text-custom-text-200" />}
|
||||
{label}
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
@ -15,7 +15,6 @@ export type TFiltersRowProps<K extends TFilterProperty, E extends TExternalFilte
|
||||
filter: IFilterInstance<K, E>;
|
||||
variant?: "default" | "header";
|
||||
visible?: boolean;
|
||||
maxVisibleConditions?: number;
|
||||
trackerElements?: {
|
||||
clearFilter?: string;
|
||||
saveView?: string;
|
||||
@ -31,25 +30,10 @@ export const FiltersRow = observer(
|
||||
filter,
|
||||
variant = "header",
|
||||
visible = true,
|
||||
maxVisibleConditions = 3,
|
||||
trackerElements,
|
||||
} = props;
|
||||
// states
|
||||
const [showAllConditions, setShowAllConditions] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
// derived values
|
||||
const visibleConditions = useMemo(() => {
|
||||
if (variant === "default" || !maxVisibleConditions || showAllConditions) {
|
||||
return filter.allConditionsForDisplay;
|
||||
}
|
||||
return filter.allConditionsForDisplay.slice(0, maxVisibleConditions);
|
||||
}, [filter.allConditionsForDisplay, maxVisibleConditions, showAllConditions, variant]);
|
||||
const hiddenConditionsCount = useMemo(() => {
|
||||
if (variant === "default" || !maxVisibleConditions || showAllConditions) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, filter.allConditionsForDisplay.length - maxVisibleConditions);
|
||||
}, [filter.allConditionsForDisplay.length, maxVisibleConditions, showAllConditions, variant]);
|
||||
|
||||
const handleUpdate = useCallback(async () => {
|
||||
setIsUpdating(true);
|
||||
@ -61,44 +45,17 @@ export const FiltersRow = observer(
|
||||
|
||||
const leftContent = (
|
||||
<>
|
||||
{filter.allConditionsForDisplay.map((condition) => (
|
||||
<FilterItem key={condition.id} filter={filter} condition={condition} isDisabled={disabledAllOperations} />
|
||||
))}
|
||||
<AddFilterButton
|
||||
filter={filter}
|
||||
buttonConfig={{
|
||||
variant: "neutral-primary",
|
||||
...buttonConfig,
|
||||
isDisabled: disabledAllOperations,
|
||||
}}
|
||||
onFilterSelect={() => {
|
||||
if (variant === "header") {
|
||||
setShowAllConditions(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{visibleConditions.map((condition) => (
|
||||
<FilterItem key={condition.id} filter={filter} condition={condition} isDisabled={disabledAllOperations} />
|
||||
))}
|
||||
{variant === "header" && hiddenConditionsCount > 0 && (
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
className={COMMON_VISIBILITY_BUTTON_CLASSNAME}
|
||||
onClick={() => setShowAllConditions(true)}
|
||||
>
|
||||
+{hiddenConditionsCount} more
|
||||
</Button>
|
||||
)}
|
||||
{variant === "header" &&
|
||||
showAllConditions &&
|
||||
maxVisibleConditions &&
|
||||
filter.allConditionsForDisplay.length > maxVisibleConditions && (
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
className={COMMON_VISIBILITY_BUTTON_CLASSNAME}
|
||||
onClick={() => setShowAllConditions(false)}
|
||||
>
|
||||
Show less
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -162,7 +119,6 @@ export const FiltersRow = observer(
|
||||
}
|
||||
);
|
||||
|
||||
const COMMON_VISIBILITY_BUTTON_CLASSNAME = "py-0.5 px-2 text-custom-text-300 hover:text-custom-text-100 rounded-full";
|
||||
const COMMON_OPERATION_BUTTON_CLASSNAME = "py-1";
|
||||
|
||||
type TElementTransitionProps = {
|
||||
|
||||
@ -1,53 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Layers } from "lucide-react";
|
||||
// plane constants
|
||||
// plane imports
|
||||
import { ETabIndices, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EmojiPicker, EmojiIconPickerTypes } from "@plane/propel/emoji-icon-picker";
|
||||
// types
|
||||
import {
|
||||
EViewAccess,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
IProjectView,
|
||||
EIssueLayoutTypes,
|
||||
EIssuesStoreType,
|
||||
IIssueFilters,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Button, Input, TextArea } from "@plane/ui";
|
||||
import { getComputedDisplayFilters, getComputedDisplayProperties, getTabIndex } from "@plane/utils";
|
||||
// components
|
||||
import { Logo } from "@/components/common/logo";
|
||||
import {
|
||||
AppliedFiltersList,
|
||||
DisplayFiltersSelection,
|
||||
FilterSelection,
|
||||
FiltersDropdown,
|
||||
} from "@/components/issues/issue-layouts/filters";
|
||||
// helpers
|
||||
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/work-item-filters-row";
|
||||
// hooks
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
// plane web imports
|
||||
import { AccessController } from "@/plane-web/components/views/access-controller";
|
||||
// local imports
|
||||
import { LayoutDropDown } from "../dropdowns/layout";
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "../work-item-filters/filters-hoc/project-level";
|
||||
|
||||
type Props = {
|
||||
data?: IProjectView | null;
|
||||
handleClose: () => void;
|
||||
handleFormSubmit: (values: IProjectView) => Promise<void>;
|
||||
preLoadedData?: Partial<IProjectView> | null;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IProjectView> = {
|
||||
const DEFAULT_VALUES: Partial<IProjectView> = {
|
||||
name: "",
|
||||
description: "",
|
||||
access: EViewAccess.PUBLIC,
|
||||
@ -56,23 +50,24 @@ const defaultValues: Partial<IProjectView> = {
|
||||
};
|
||||
|
||||
export const ProjectViewForm: React.FC<Props> = observer((props) => {
|
||||
const { handleFormSubmit, handleClose, data, preLoadedData } = props;
|
||||
const { handleFormSubmit, handleClose, data, preLoadedData, projectId, workspaceSlug } = props;
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// state
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
const { getProjectById } = useProject();
|
||||
const { isMobile } = usePlatformOS();
|
||||
// form info
|
||||
const defaultValues = {
|
||||
...DEFAULT_VALUES,
|
||||
...preLoadedData,
|
||||
...data,
|
||||
};
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
getValues,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
@ -80,53 +75,23 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
|
||||
} = useForm<IProjectView>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
// derived values
|
||||
const projectDetails = getProjectById(projectId);
|
||||
const logoValue = watch("logo_props");
|
||||
|
||||
const { getIndex } = getTabIndex(ETabIndices.PROJECT_VIEW, isMobile);
|
||||
|
||||
const selectedFilters: IIssueFilterOptions = {};
|
||||
Object.entries(watch("filters") ?? {}).forEach(([key, value]) => {
|
||||
if (!value) return;
|
||||
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
|
||||
selectedFilters[key as keyof IIssueFilterOptions] = value;
|
||||
});
|
||||
|
||||
// for removing filters from a key
|
||||
const handleRemoveFilter = (key: keyof IIssueFilterOptions, value: string | null) => {
|
||||
// If value is null then remove all the filters of that key
|
||||
if (!value) {
|
||||
setValue("filters", {
|
||||
...selectedFilters,
|
||||
[key]: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newValues = selectedFilters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (newValues.includes(val)) newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (selectedFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
}
|
||||
|
||||
setValue("filters", {
|
||||
...selectedFilters,
|
||||
[key]: newValues,
|
||||
});
|
||||
const workItemFilters: IIssueFilters = {
|
||||
richFilters: getValues("rich_filters"),
|
||||
displayFilters: getValues("display_filters"),
|
||||
displayProperties: getValues("display_properties"),
|
||||
kanbanFilters: undefined,
|
||||
};
|
||||
const { getIndex } = getTabIndex(ETabIndices.PROJECT_VIEW, isMobile);
|
||||
|
||||
const handleCreateUpdateView = async (formData: IProjectView) => {
|
||||
await handleFormSubmit({
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
logo_props: formData.logo_props,
|
||||
filters: formData.filters,
|
||||
rich_filters: formData.rich_filters,
|
||||
display_filters: formData.display_filters,
|
||||
display_properties: formData.display_properties,
|
||||
access: formData.access,
|
||||
@ -137,20 +102,6 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
if (!selectedFilters) return;
|
||||
|
||||
setValue("filters", {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...preLoadedData,
|
||||
...data,
|
||||
});
|
||||
}, [data, preLoadedData, reset]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleCreateUpdateView)}>
|
||||
<div className="space-y-5 p-5">
|
||||
@ -263,43 +214,6 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
|
||||
}
|
||||
value={displayFilters.layout}
|
||||
/>
|
||||
|
||||
{/* filters dropdown */}
|
||||
<Controller
|
||||
control={control}
|
||||
name="filters"
|
||||
render={({ field: { onChange, value: filters } }) => (
|
||||
<FiltersDropdown title={t("common.filters")} tabIndex={getIndex("filters")}>
|
||||
<FilterSelection
|
||||
filters={filters ?? {}}
|
||||
handleFiltersUpdate={(key, value) => {
|
||||
const newValues = filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
});
|
||||
} else {
|
||||
if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
onChange({
|
||||
...filters,
|
||||
[key]: newValues,
|
||||
});
|
||||
}}
|
||||
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[displayFilters.layout]}
|
||||
labels={projectLabels ?? undefined}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* display filters dropdown */}
|
||||
<Controller
|
||||
control={control}
|
||||
@ -307,7 +221,9 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
|
||||
render={({ field: { onChange: onDisplayPropertiesChange, value: displayProperties } }) => (
|
||||
<FiltersDropdown title={t("common.display")}>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[displayFilters.layout]}
|
||||
layoutDisplayFiltersOptions={
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[displayFilters.layout]
|
||||
}
|
||||
displayFilters={displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
onDisplayFiltersChange({
|
||||
@ -324,8 +240,8 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
|
||||
...updatedDisplayProperties,
|
||||
});
|
||||
}}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
cycleViewDisabled={!projectDetails?.cycle_view}
|
||||
moduleViewDisabled={!projectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
)}
|
||||
@ -334,17 +250,31 @@ export const ProjectViewForm: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{selectedFilters && Object.keys(selectedFilters).length > 0 && (
|
||||
<div>
|
||||
<AppliedFiltersList
|
||||
appliedFilters={selectedFilters}
|
||||
handleClearAllFilters={clearAllFilters}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
labels={projectLabels ?? []}
|
||||
states={projectStates}
|
||||
{/* filters dropdown */}
|
||||
<Controller
|
||||
control={control}
|
||||
name="rich_filters"
|
||||
render={({ field: { onChange: onFiltersChange } }) => (
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
entityId={data?.id}
|
||||
entityType={EIssuesStoreType.PROJECT_VIEW}
|
||||
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.filters}
|
||||
initialWorkItemFilters={workItemFilters}
|
||||
isTemporary
|
||||
updateFilters={(updateFilters) => onFiltersChange(updateFilters)}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
>
|
||||
{({ filter: projectViewWorkItemsFilter }) =>
|
||||
projectViewWorkItemsFilter && (
|
||||
<WorkItemFiltersRow filter={projectViewWorkItemsFilter} variant="default" />
|
||||
)
|
||||
}
|
||||
</ProjectLevelWorkItemFiltersHOC>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { EIssueLayoutTypes } from "@plane/types";
|
||||
import { WorkspaceSpreadsheetRoot } from "@/components/issues/issue-layouts/spreadsheet/roots/workspace-root";
|
||||
import { WorkspaceAdditionalLayouts } from "@/plane-web/components/views/helper";
|
||||
import { WorkspaceSpreadsheetRoot } from "../issues/issue-layouts/spreadsheet/roots/workspace-root";
|
||||
|
||||
export type TWorkspaceLayoutProps = {
|
||||
activeLayout: EIssueLayoutTypes | undefined;
|
||||
|
||||
@ -4,12 +4,14 @@ import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { PROJECT_VIEW_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { IProjectView } from "@plane/types";
|
||||
import { EIssuesStoreType, IProjectView } from "@plane/types";
|
||||
// ui
|
||||
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useProjectView } from "@/hooks/store/use-project-view";
|
||||
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
// local imports
|
||||
@ -30,6 +32,10 @@ export const CreateUpdateProjectViewModal: FC<Props> = observer((props) => {
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { createView, updateView } = useProjectView();
|
||||
const {
|
||||
issuesFilter: { mutateFilters },
|
||||
} = useIssues(EIssuesStoreType.PROJECT_VIEW);
|
||||
const { resetExpression } = useWorkItemFilters();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
@ -66,7 +72,9 @@ export const CreateUpdateProjectViewModal: FC<Props> = observer((props) => {
|
||||
|
||||
const handleUpdateView = async (payload: IProjectView) => {
|
||||
await updateView(workspaceSlug, projectId, data?.id as string, payload)
|
||||
.then(() => {
|
||||
.then((viewDetails) => {
|
||||
mutateFilters(workspaceSlug, viewDetails.id, viewDetails);
|
||||
resetExpression(EIssuesStoreType.PROJECT_VIEW, viewDetails.id, viewDetails.rich_filters);
|
||||
handleClose();
|
||||
captureSuccess({
|
||||
eventName: PROJECT_VIEW_TRACKER_EVENTS.update,
|
||||
@ -106,6 +114,8 @@ export const CreateUpdateProjectViewModal: FC<Props> = observer((props) => {
|
||||
handleClose={handleClose}
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
preLoadedData={preLoadedData}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</ModalCore>
|
||||
);
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
import { SetStateAction, useEffect, useState } from "react";
|
||||
import { Button } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
isLocked: boolean;
|
||||
areFiltersEqual: boolean;
|
||||
isOwner: boolean;
|
||||
isAuthorizedUser: boolean;
|
||||
setIsModalOpen: (value: SetStateAction<boolean>) => void;
|
||||
handleUpdateView: () => void;
|
||||
lockedTooltipContent?: string;
|
||||
trackerElement: string;
|
||||
};
|
||||
|
||||
export const UpdateViewComponent = (props: Props) => {
|
||||
const { isLocked, areFiltersEqual, isOwner, isAuthorizedUser, setIsModalOpen, handleUpdateView, trackerElement } =
|
||||
props;
|
||||
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (areFiltersEqual) {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [areFiltersEqual]);
|
||||
|
||||
// Change state while updating view to have a feedback
|
||||
const updateButton = isUpdating ? (
|
||||
<Button variant="primary" size="sm" className="flex-shrink-0">
|
||||
Updating...
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
onClick={() => {
|
||||
setIsUpdating(true);
|
||||
handleUpdateView();
|
||||
}}
|
||||
>
|
||||
Update view
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 h-fit">
|
||||
{!isLocked && !areFiltersEqual && isAuthorizedUser && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
size="md"
|
||||
className="flex-shrink-0"
|
||||
data-ph-element={trackerElement}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
Save as
|
||||
</Button>
|
||||
{isOwner && <>{updateButton}</>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -8,7 +8,7 @@ import { useLocalStorage } from "@plane/hooks";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { EViewAccess, IProjectView } from "@plane/types";
|
||||
import { FavoriteStar } from "@plane/ui";
|
||||
import { calculateTotalFilters, getPublishViewLink } from "@plane/utils";
|
||||
import { getPublishViewLink } from "@plane/utils";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProjectView } from "@/hooks/store/use-project-view";
|
||||
@ -52,8 +52,6 @@ export const ViewListItemAction: FC<Props> = observer((props) => {
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
|
||||
const totalFilters = calculateTotalFilters(view.filters ?? {});
|
||||
|
||||
const access = view.access;
|
||||
|
||||
const publishLink = getPublishViewLink(view?.anchor);
|
||||
@ -87,10 +85,6 @@ export const ViewListItemAction: FC<Props> = observer((props) => {
|
||||
/>
|
||||
)}
|
||||
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
|
||||
<p className="hidden rounded bg-custom-background-80 px-2 py-1 text-xs text-custom-text-200 group-hover:block">
|
||||
{totalFilters} {totalFilters === 1 ? "filter" : "filters"}
|
||||
</p>
|
||||
|
||||
<div className="cursor-default text-custom-text-300">
|
||||
<Tooltip tooltipContent={access === EViewAccess.PUBLIC ? "Public" : "Private"}>
|
||||
{access === EViewAccess.PUBLIC ? <Earth className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
|
||||
|
||||
100
apps/web/core/components/work-item-filters/filters-hoc/base.tsx
Normal file
100
apps/web/core/components/work-item-filters/filters-hoc/base.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// plane imports
|
||||
import { TSaveViewOptions, TUpdateViewOptions } from "@plane/constants";
|
||||
import { IFilterInstance } from "@plane/shared-state";
|
||||
import { IIssueFilters, TWorkItemFilterExpression, TWorkItemFilterProperty } from "@plane/types";
|
||||
// store hooks
|
||||
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
|
||||
// plane web imports
|
||||
import {
|
||||
TWorkItemFiltersEntityProps,
|
||||
useWorkItemFiltersConfig,
|
||||
} from "@/plane-web/hooks/work-item-filters/use-work-item-filters-config";
|
||||
// local imports
|
||||
import { TSharedWorkItemFiltersHOCProps, TSharedWorkItemFiltersProps } from "./shared";
|
||||
|
||||
type TAdditionalWorkItemFiltersProps = {
|
||||
saveViewOptions?: TSaveViewOptions<TWorkItemFilterExpression>;
|
||||
updateViewOptions?: TUpdateViewOptions<TWorkItemFilterExpression>;
|
||||
} & TWorkItemFiltersEntityProps;
|
||||
|
||||
type TWorkItemFiltersHOCProps = TSharedWorkItemFiltersHOCProps & TAdditionalWorkItemFiltersProps;
|
||||
|
||||
export const WorkItemFiltersHOC = observer((props: TWorkItemFiltersHOCProps) => {
|
||||
const { children, initialWorkItemFilters } = props;
|
||||
|
||||
// Only initialize filter instance when initial work item filters are defined
|
||||
if (!initialWorkItemFilters)
|
||||
return <>{typeof children === "function" ? children({ filter: undefined }) : children}</>;
|
||||
|
||||
return (
|
||||
<WorkItemFilterRoot {...props} initialWorkItemFilters={initialWorkItemFilters}>
|
||||
{children}
|
||||
</WorkItemFilterRoot>
|
||||
);
|
||||
});
|
||||
|
||||
type TWorkItemFilterProps = TSharedWorkItemFiltersProps &
|
||||
TAdditionalWorkItemFiltersProps & {
|
||||
initialWorkItemFilters: IIssueFilters;
|
||||
children:
|
||||
| React.ReactNode
|
||||
| ((props: { filter: IFilterInstance<TWorkItemFilterProperty, TWorkItemFilterExpression> }) => React.ReactNode);
|
||||
};
|
||||
|
||||
const WorkItemFilterRoot = observer((props: TWorkItemFilterProps) => {
|
||||
const {
|
||||
children,
|
||||
entityType,
|
||||
entityId,
|
||||
filtersToShowByLayout,
|
||||
initialWorkItemFilters,
|
||||
isTemporary,
|
||||
saveViewOptions,
|
||||
updateFilters,
|
||||
updateViewOptions,
|
||||
...entityConfigProps
|
||||
} = props;
|
||||
// store hooks
|
||||
const { getOrCreateFilter, deleteFilter } = useWorkItemFilters();
|
||||
// derived values
|
||||
const workItemEntityID = useMemo(
|
||||
() => (isTemporary ? `TEMP-${entityId ?? uuidv4()}` : entityId),
|
||||
[isTemporary, entityId]
|
||||
);
|
||||
// memoize initial values to prevent re-computations when reference changes
|
||||
const initialUserFilters = useMemo(
|
||||
() => initialWorkItemFilters.richFilters,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
); // Empty dependency array to capture only the initial value
|
||||
const workItemFiltersConfig = useWorkItemFiltersConfig({
|
||||
allowedFilters: filtersToShowByLayout ? filtersToShowByLayout : [],
|
||||
...entityConfigProps,
|
||||
});
|
||||
// get or create filter instance
|
||||
const workItemLayoutFilter = getOrCreateFilter({
|
||||
entityType,
|
||||
entityId: workItemEntityID,
|
||||
initialExpression: initialUserFilters,
|
||||
onExpressionChange: updateFilters,
|
||||
expressionOptions: {
|
||||
saveViewOptions,
|
||||
updateViewOptions,
|
||||
},
|
||||
});
|
||||
|
||||
// delete filter instance when component unmounts
|
||||
useEffect(
|
||||
() => () => {
|
||||
deleteFilter(entityType, workItemEntityID);
|
||||
},
|
||||
[deleteFilter, entityType, workItemEntityID]
|
||||
);
|
||||
|
||||
workItemLayoutFilter.configManager.registerAll(workItemFiltersConfig.configs);
|
||||
|
||||
return <>{typeof children === "function" ? children({ filter: workItemLayoutFilter }) : children}</>;
|
||||
});
|
||||
@ -0,0 +1,206 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel, PROJECT_VIEW_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { EUserProjectRoles, EViewAccess, IProjectView, TWorkItemFilterExpression } from "@plane/types";
|
||||
// components
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
import { removeNillKeys } from "@/components/issues/issue-layouts/utils";
|
||||
import { CreateUpdateProjectViewModal } from "@/components/views/modal";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useProjectView } from "@/hooks/store/use-project-view";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { getAdditionalProjectLevelFiltersHOCProps } from "@/plane-web/helpers/work-item-filters/project-level";
|
||||
// local imports
|
||||
import { WorkItemFiltersHOC } from "./base";
|
||||
import { TEnableSaveViewProps, TEnableUpdateViewProps, TSharedWorkItemFiltersHOCProps } from "./shared";
|
||||
|
||||
type TProjectLevelWorkItemFiltersHOCProps = TSharedWorkItemFiltersHOCProps & {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
} & TEnableSaveViewProps &
|
||||
TEnableUpdateViewProps;
|
||||
|
||||
export const ProjectLevelWorkItemFiltersHOC = observer((props: TProjectLevelWorkItemFiltersHOCProps) => {
|
||||
const { children, enableSaveView, enableUpdateView, entityId, initialWorkItemFilters, projectId, workspaceSlug } =
|
||||
props;
|
||||
// states
|
||||
const [isCreateViewModalOpen, setIsCreateViewModalOpen] = useState(false);
|
||||
const [createViewPayload, setCreateViewPayload] = useState<Partial<IProjectView> | null>(null);
|
||||
// hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { getViewById, updateView } = useProjectView();
|
||||
const { data: currentUser } = useUser();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { getProjectCycleIds } = useCycle();
|
||||
const { getProjectLabelIds } = useLabel();
|
||||
const {
|
||||
project: { getProjectMemberIds },
|
||||
} = useMember();
|
||||
const { getProjectModuleIds } = useModule();
|
||||
const { getProjectStateIds } = useProjectState();
|
||||
// derived values
|
||||
const hasProjectMemberLevelPermissions = allowPermissions(
|
||||
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
const projectDetails = getProjectById(projectId);
|
||||
const viewDetails = entityId ? getViewById(entityId) : null;
|
||||
const isViewLocked = viewDetails ? viewDetails?.is_locked : false;
|
||||
const isCurrentUserOwner = viewDetails ? viewDetails.owned_by === currentUser?.id : false;
|
||||
const canCreateView = useMemo(
|
||||
() =>
|
||||
projectDetails?.issue_views_view === true &&
|
||||
enableSaveView &&
|
||||
!props.saveViewOptions?.isDisabled &&
|
||||
hasProjectMemberLevelPermissions,
|
||||
[
|
||||
projectDetails?.issue_views_view,
|
||||
enableSaveView,
|
||||
props.saveViewOptions?.isDisabled,
|
||||
hasProjectMemberLevelPermissions,
|
||||
]
|
||||
);
|
||||
const canUpdateView = useMemo(
|
||||
() =>
|
||||
enableUpdateView &&
|
||||
!props.updateViewOptions?.isDisabled &&
|
||||
!isViewLocked &&
|
||||
hasProjectMemberLevelPermissions &&
|
||||
isCurrentUserOwner,
|
||||
[
|
||||
enableUpdateView,
|
||||
props.updateViewOptions?.isDisabled,
|
||||
isViewLocked,
|
||||
hasProjectMemberLevelPermissions,
|
||||
isCurrentUserOwner,
|
||||
]
|
||||
);
|
||||
|
||||
const getDefaultViewDetailPayload: () => Partial<IProjectView> = useCallback(
|
||||
() => ({
|
||||
name: viewDetails ? `${viewDetails?.name} 2` : "Untitled",
|
||||
description: viewDetails ? viewDetails.description : "",
|
||||
logo_props: viewDetails ? viewDetails.logo_props : undefined,
|
||||
access: viewDetails ? viewDetails.access : EViewAccess.PUBLIC,
|
||||
}),
|
||||
[viewDetails]
|
||||
);
|
||||
|
||||
const getViewFilterPayload: (filterExpression: TWorkItemFilterExpression) => Partial<IProjectView> = useCallback(
|
||||
(filterExpression: TWorkItemFilterExpression) => ({
|
||||
rich_filters: cloneDeep(filterExpression),
|
||||
display_filters: cloneDeep(initialWorkItemFilters?.displayFilters),
|
||||
display_properties: cloneDeep(initialWorkItemFilters?.displayProperties),
|
||||
}),
|
||||
[initialWorkItemFilters]
|
||||
);
|
||||
|
||||
const handleViewUpdate = useCallback(
|
||||
(filterExpression: TWorkItemFilterExpression) => {
|
||||
if (!viewDetails) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "We couldn't find the view",
|
||||
message: "The view you're trying to update doesn't exist.",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
updateView(workspaceSlug, projectId, viewDetails.id, {
|
||||
...getViewFilterPayload(filterExpression),
|
||||
})
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Your view has been updated successfully.",
|
||||
});
|
||||
captureSuccess({
|
||||
eventName: PROJECT_VIEW_TRACKER_EVENTS.update,
|
||||
payload: {
|
||||
view_id: viewDetails.id,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Your view could not be updated. Please try again.",
|
||||
});
|
||||
captureError({
|
||||
eventName: PROJECT_VIEW_TRACKER_EVENTS.update,
|
||||
payload: {
|
||||
view_id: viewDetails.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
[viewDetails, updateView, workspaceSlug, projectId, getViewFilterPayload]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateProjectViewModal
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
preLoadedData={createViewPayload}
|
||||
isOpen={isCreateViewModalOpen}
|
||||
onClose={() => {
|
||||
setCreateViewPayload(null);
|
||||
setIsCreateViewModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
<WorkItemFiltersHOC
|
||||
{...props}
|
||||
{...getAdditionalProjectLevelFiltersHOCProps({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
})}
|
||||
cycleIds={getProjectCycleIds(projectId) ?? undefined}
|
||||
labelIds={getProjectLabelIds(projectId)}
|
||||
memberIds={getProjectMemberIds(projectId, false) ?? undefined}
|
||||
moduleIds={getProjectModuleIds(projectId) ?? undefined}
|
||||
stateIds={getProjectStateIds(projectId)}
|
||||
saveViewOptions={{
|
||||
label: props.saveViewOptions?.label,
|
||||
isDisabled: !canCreateView,
|
||||
onViewSave: (expression) => {
|
||||
setCreateViewPayload({
|
||||
...getDefaultViewDetailPayload(),
|
||||
...getViewFilterPayload(expression),
|
||||
});
|
||||
setIsCreateViewModalOpen(true);
|
||||
},
|
||||
}}
|
||||
updateViewOptions={{
|
||||
label: props.updateViewOptions?.label,
|
||||
isDisabled: !canUpdateView,
|
||||
hasAdditionalChanges:
|
||||
!isEqual(initialWorkItemFilters?.displayFilters, viewDetails?.display_filters) ||
|
||||
!isEqual(
|
||||
removeNillKeys(initialWorkItemFilters?.displayProperties),
|
||||
removeNillKeys(viewDetails?.display_properties)
|
||||
),
|
||||
onViewUpdate: handleViewUpdate,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WorkItemFiltersHOC>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,30 @@
|
||||
// plane imports
|
||||
import { TSaveViewOptions, TUpdateViewOptions } from "@plane/constants";
|
||||
import { IFilterInstance } from "@plane/shared-state";
|
||||
import { EIssuesStoreType, IIssueFilters, TWorkItemFilterExpression, TWorkItemFilterProperty } from "@plane/types";
|
||||
|
||||
export type TSharedWorkItemFiltersProps = {
|
||||
entityType: EIssuesStoreType; // entity type (project, cycle, workspace, teamspace, etc)
|
||||
filtersToShowByLayout: TWorkItemFilterProperty[];
|
||||
updateFilters: (updatedFilters: TWorkItemFilterExpression) => void;
|
||||
isTemporary?: boolean;
|
||||
} & ({ isTemporary: true; entityId?: string } | { isTemporary?: false; entityId: string }); // entity id (project_id, cycle_id, workspace_id, etc)
|
||||
|
||||
export type TSharedWorkItemFiltersHOCProps = TSharedWorkItemFiltersProps & {
|
||||
children:
|
||||
| React.ReactNode
|
||||
| ((props: {
|
||||
filter: IFilterInstance<TWorkItemFilterProperty, TWorkItemFilterExpression> | undefined;
|
||||
}) => React.ReactNode);
|
||||
initialWorkItemFilters: IIssueFilters | undefined;
|
||||
};
|
||||
|
||||
export type TEnableSaveViewProps = {
|
||||
enableSaveView?: boolean;
|
||||
saveViewOptions?: Omit<TSaveViewOptions<TWorkItemFilterExpression>, "onViewSave">;
|
||||
};
|
||||
|
||||
export type TEnableUpdateViewProps = {
|
||||
enableUpdateView?: boolean;
|
||||
updateViewOptions?: Omit<TUpdateViewOptions<TWorkItemFilterExpression>, "onViewUpdate">;
|
||||
};
|
||||
@ -0,0 +1,185 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { DEFAULT_GLOBAL_VIEWS_LIST, EUserPermissionsLevel, GLOBAL_VIEW_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { EUserProjectRoles, EViewAccess, IWorkspaceView, TWorkItemFilterExpression } from "@plane/types";
|
||||
// components
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
import { removeNillKeys } from "@/components/issues/issue-layouts/utils";
|
||||
import { CreateUpdateWorkspaceViewModal } from "@/components/workspace/views/modal";
|
||||
// hooks
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { useGlobalView } from "@/hooks/store/use-global-view";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { WorkItemFiltersHOC } from "./base";
|
||||
import { TEnableSaveViewProps, TEnableUpdateViewProps, TSharedWorkItemFiltersHOCProps } from "./shared";
|
||||
|
||||
type TWorkspaceLevelWorkItemFiltersHOCProps = TSharedWorkItemFiltersHOCProps & {
|
||||
workspaceSlug: string;
|
||||
} & TEnableSaveViewProps &
|
||||
TEnableUpdateViewProps;
|
||||
|
||||
export const WorkspaceLevelWorkItemFiltersHOC = observer((props: TWorkspaceLevelWorkItemFiltersHOCProps) => {
|
||||
const { children, enableSaveView, enableUpdateView, entityId, initialWorkItemFilters, workspaceSlug } = props;
|
||||
// states
|
||||
const [isCreateViewModalOpen, setIsCreateViewModalOpen] = useState(false);
|
||||
const [createViewPayload, setCreateViewPayload] = useState<Partial<IWorkspaceView> | undefined>(undefined);
|
||||
// hooks
|
||||
const { getViewDetailsById, updateGlobalView } = useGlobalView();
|
||||
const { data: currentUser } = useUser();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { joinedProjectIds } = useProject();
|
||||
const {
|
||||
workspace: { getWorkspaceMemberIds },
|
||||
} = useMember();
|
||||
const { getWorkspaceLabelIds } = useLabel();
|
||||
// derived values
|
||||
const hasWorkspaceMemberLevelPermissions = allowPermissions(
|
||||
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE,
|
||||
workspaceSlug
|
||||
);
|
||||
const viewDetails = entityId ? getViewDetailsById(entityId) : null;
|
||||
const isDefaultView = typeof entityId === "string" && DEFAULT_GLOBAL_VIEWS_LIST.some((view) => view.key === entityId);
|
||||
const isViewLocked = viewDetails ? viewDetails?.is_locked : false;
|
||||
const isCurrentUserOwner = viewDetails ? viewDetails.owned_by === currentUser?.id : false;
|
||||
const canCreateView = useMemo(
|
||||
() => enableSaveView && !props.saveViewOptions?.isDisabled && hasWorkspaceMemberLevelPermissions,
|
||||
[enableSaveView, props.saveViewOptions?.isDisabled, hasWorkspaceMemberLevelPermissions]
|
||||
);
|
||||
const canUpdateView = useMemo(
|
||||
() =>
|
||||
enableUpdateView &&
|
||||
!isDefaultView &&
|
||||
!props.updateViewOptions?.isDisabled &&
|
||||
!isViewLocked &&
|
||||
hasWorkspaceMemberLevelPermissions &&
|
||||
isCurrentUserOwner,
|
||||
[
|
||||
enableUpdateView,
|
||||
props.updateViewOptions?.isDisabled,
|
||||
isDefaultView,
|
||||
isViewLocked,
|
||||
hasWorkspaceMemberLevelPermissions,
|
||||
isCurrentUserOwner,
|
||||
]
|
||||
);
|
||||
|
||||
const getDefaultViewDetailPayload: () => Partial<IWorkspaceView> = useCallback(
|
||||
() => ({
|
||||
name: viewDetails ? `${viewDetails?.name} 2` : "Untitled",
|
||||
description: viewDetails ? viewDetails.description : "",
|
||||
access: viewDetails ? viewDetails.access : EViewAccess.PUBLIC,
|
||||
}),
|
||||
[viewDetails]
|
||||
);
|
||||
|
||||
const getViewFilterPayload: (filterExpression: TWorkItemFilterExpression) => Partial<IWorkspaceView> = useCallback(
|
||||
(filterExpression: TWorkItemFilterExpression) => ({
|
||||
rich_filters: cloneDeep(filterExpression),
|
||||
display_filters: cloneDeep(initialWorkItemFilters?.displayFilters),
|
||||
display_properties: cloneDeep(initialWorkItemFilters?.displayProperties),
|
||||
}),
|
||||
[initialWorkItemFilters]
|
||||
);
|
||||
|
||||
const handleViewUpdate = useCallback(
|
||||
(filterExpression: TWorkItemFilterExpression) => {
|
||||
if (!viewDetails) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "We couldn't find the view",
|
||||
message: "The view you're trying to update doesn't exist.",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
updateGlobalView(
|
||||
workspaceSlug,
|
||||
viewDetails.id,
|
||||
{
|
||||
...getViewFilterPayload(filterExpression),
|
||||
},
|
||||
/* No need to sync filters here as updateFilters already handles it */
|
||||
false
|
||||
)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Your view has been updated successfully.",
|
||||
});
|
||||
captureSuccess({
|
||||
eventName: GLOBAL_VIEW_TRACKER_EVENTS.update,
|
||||
payload: {
|
||||
view_id: viewDetails.id,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Your view could not be updated. Please try again.",
|
||||
});
|
||||
captureError({
|
||||
eventName: GLOBAL_VIEW_TRACKER_EVENTS.update,
|
||||
payload: {
|
||||
view_id: viewDetails.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
[viewDetails, updateGlobalView, workspaceSlug, getViewFilterPayload]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateWorkspaceViewModal
|
||||
preLoadedData={createViewPayload}
|
||||
isOpen={isCreateViewModalOpen}
|
||||
onClose={() => {
|
||||
setCreateViewPayload(undefined);
|
||||
setIsCreateViewModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
<WorkItemFiltersHOC
|
||||
{...props}
|
||||
memberIds={getWorkspaceMemberIds(workspaceSlug)}
|
||||
labelIds={getWorkspaceLabelIds(workspaceSlug)}
|
||||
projectIds={joinedProjectIds}
|
||||
saveViewOptions={{
|
||||
label: props.saveViewOptions?.label,
|
||||
isDisabled: !canCreateView,
|
||||
onViewSave: (expression) => {
|
||||
setCreateViewPayload({
|
||||
...getDefaultViewDetailPayload(),
|
||||
...getViewFilterPayload(expression),
|
||||
});
|
||||
setIsCreateViewModalOpen(true);
|
||||
},
|
||||
}}
|
||||
updateViewOptions={{
|
||||
label: props.updateViewOptions?.label,
|
||||
isDisabled: !canUpdateView,
|
||||
hasAdditionalChanges:
|
||||
!isEqual(initialWorkItemFilters?.displayFilters, viewDetails?.display_filters) ||
|
||||
!isEqual(
|
||||
removeNillKeys(initialWorkItemFilters?.displayProperties),
|
||||
removeNillKeys(viewDetails?.display_properties)
|
||||
),
|
||||
onViewUpdate: handleViewUpdate,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WorkItemFiltersHOC>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { TWorkItemFilterExpression, TWorkItemFilterProperty } from "@plane/types";
|
||||
// components
|
||||
import { FiltersRow, TFiltersRowProps } from "@/components/rich-filters/filters-row";
|
||||
|
||||
type TWorkItemFiltersRowProps = TFiltersRowProps<TWorkItemFilterProperty, TWorkItemFilterExpression>;
|
||||
|
||||
export const WorkItemFiltersRow = observer((props: TWorkItemFiltersRowProps) => <FiltersRow {...props} />);
|
||||
@ -2,12 +2,11 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { FileText, HelpCircle, MessagesSquare, User } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { CustomMenu, ToggleSwitch } from "@plane/ui";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { cn } from "@plane/utils";
|
||||
import { ProductUpdatesModal } from "@/components/global";
|
||||
@ -16,7 +15,6 @@ import { ProductUpdatesModal } from "@/components/global";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
import { useTransient } from "@/hooks/store/use-transient";
|
||||
import { useUserSettings } from "@/hooks/store/user";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { PlaneVersionNumber } from "@/plane-web/components/global";
|
||||
@ -26,14 +24,12 @@ export interface WorkspaceHelpSectionProps {
|
||||
}
|
||||
|
||||
export const HelpMenu: React.FC<WorkspaceHelpSectionProps> = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { toggleShortcutModal } = useCommandPalette();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { config } = useInstance();
|
||||
const { isIntercomToggle, toggleIntercom } = useTransient();
|
||||
const { canUseLocalDB, toggleLocalDB } = useUserSettings();
|
||||
// states
|
||||
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
|
||||
const [isProductUpdatesModalOpen, setProductUpdatesModalOpen] = useState(false);
|
||||
@ -101,21 +97,6 @@ export const HelpMenu: React.FC<WorkspaceHelpSectionProps> = observer(() => {
|
||||
</a>
|
||||
</CustomMenu.MenuItem>
|
||||
<div className="my-1 border-t border-custom-border-200" />
|
||||
<CustomMenu.MenuItem>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex w-full items-center justify-between text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<span className="racking-tight">{t("hyper_mode")}</span>
|
||||
<ToggleSwitch
|
||||
value={canUseLocalDB}
|
||||
onChange={() => toggleLocalDB(workspaceSlug?.toString(), projectId?.toString())}
|
||||
/>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user