[WEB-4951] [WEB-4884] feat: work item filters revamp (#7810)

This commit is contained in:
Prateek Shourya 2025-09-19 18:27:36 +05:30 committed by GitHub
parent e6a7ca4c72
commit 9aef5d4aa9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
160 changed files with 5879 additions and 4881 deletions

View File

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

View File

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

View File

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

View File

@ -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(
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(
# Base queryset with basic filters
queryset = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
)
# 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,

View File

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

View File

@ -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
if (module_issue.first() and module_issue.first().module)
else None
"module_name": (
module_issue.first().module.name
if (module_issue.first() and module_issue.first().module)
else None
)
}
),
epoch=int(timezone.now().timestamp()),

View File

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

View File

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

View File

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

View File

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

View 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"]

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

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

View 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

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

View File

@ -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))
return list(queryset)
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))
return list(queryset)
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))
return list(queryset)
else:
return list(queryset)
return []

View File

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

View File

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

View File

@ -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";
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 = () => {
// i18n
const { t } = useTranslation();
const [analyticsModal, setAnalyticsModal] = useState(false);
const { getCycleById } = useCycle();
const 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 },
];
// 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}

View File

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

View File

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

View File

@ -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";
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(() => {
const [analyticsModal, setAnalyticsModal] = useState(false);
const { currentProjectDetails } = useProject();
const { getModuleById } = useModule();
const { t } = useTranslation();
const 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 },
];
// 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}

View File

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

View File

@ -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>
<AllIssueLayoutRoot isDefaultView={!!defaultView} isLoading={isLoading} toggleLoading={toggleLoading} />
</>
);
});

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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={() => {
if (label.label_id) {
handleFiltersUpdate("labels", [label.label_id], true);
}
}}
onClick={
label.label_id
? () => {
if (label.label_id) {
handleFiltersUpdate([{ property: "label_id", operator: "in", value: [label.label_id] }]);
}
}
: undefined
}
/>
))
) : (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,3 +3,4 @@ export * from "./title";
export * from "./root";
export * from "./quick-action-button";
export * from "./display-filters";
export * from "./content";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,4 @@
export * from "./roots";
export * from "./date";
export * from "./filters-list";
export * from "./label";
export * from "./members";
export * from "./priority";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -116,7 +116,9 @@ 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>
<WorkFlowGroupTree groupBy={groupBy} groupId={groupID} />
<div className="px-2.5">
<WorkFlowGroupTree groupBy={groupBy} groupId={groupID} />
</div>
</div>
{!disableIssueCreation &&

View File

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

View File

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

View File

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

View File

@ -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",
{
canGroup: false,
perPageCount: 100,
}
);
routerFilterParams();
await fetchFilters(workspaceSlug, globalViewId);
await fetchIssues(workspaceSlug, globalViewId, groupedIssueIds ? "mutation" : "init-loader", {
canGroup: false,
perPageCount: 100,
});
toggleLoading(false);
}
},
@ -131,18 +112,51 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
);
}
if (!workspaceSlug || !globalViewId) return null;
return (
<WorkspaceActiveLayout
activeLayout={activeLayout}
isDefaultView={isDefaultView}
isLoading={isLoading}
toggleLoading={toggleLoading}
workspaceSlug={workspaceSlug?.toString()}
globalViewId={globalViewId?.toString()}
routeFilters={routeFilters}
fetchNextPages={fetchNextPages}
globalViewsLoading={globalViewsLoading}
issuesLoading={issuesLoading}
/>
<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}
globalViewId={globalViewId}
routeFilters={routeFilters}
fetchNextPages={fetchNextPages}
globalViewsLoading={globalViewsLoading}
issuesLoading={issuesLoading}
/>
</div>
{/* peek overview */}
<IssuePeekOverview />
</div>
)}
</WorkspaceLevelWorkItemFiltersHOC>
</IssuesStoreContext.Provider>
);
});

View File

@ -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>
<div className="relative h-full w-full overflow-auto">
<ArchivedIssueListLayout />
</div>
<IssuePeekOverview />
</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 />
</>
)}
</ProjectLevelWorkItemFiltersHOC>
</IssuesStoreContext.Provider>
);
});

View File

@ -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,31 +89,48 @@ export const CycleLayoutRoot: React.FC = observer(() => {
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.CYCLE}>
<TransferIssuesModal
handleClose={() => setTransferIssuesModal(false)}
cycleId={cycleId.toString()}
isOpen={transferIssuesModal}
/>
<div className="relative flex h-full w-full flex-col overflow-hidden">
{cycleStatus === "completed" && (
<TransferIssues
handleClick={() => setTransferIssuesModal(true)}
canTransferIssues={canTransferIssues}
disabled={!isEmpty(cycleDetails?.progress_snapshot)}
/>
<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}
isOpen={transferIssuesModal}
/>
<div className="relative flex h-full w-full flex-col overflow-hidden">
{cycleStatus === "completed" && (
<TransferIssues
handleClick={() => setTransferIssuesModal(true)}
canTransferIssues={canTransferIssues}
disabled={!isEmpty(cycleDetails?.progress_snapshot)}
/>
)}
{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>
</>
)}
<CycleAppliedFiltersRoot />
<div className="h-full w-full overflow-auto">
<CycleIssueLayout
activeLayout={activeLayout}
cycleId={cycleId?.toString()}
isCompletedCycle={isCompletedCycle}
/>
</div>
{/* peek overview */}
<IssuePeekOverview />
</div>
</ProjectLevelWorkItemFiltersHOC>
</IssuesStoreContext.Provider>
);
});

View File

@ -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}>
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ModuleAppliedFiltersRoot />
<Row variant={ERowVariant.HUGGING} className="h-full w-full overflow-auto">
<ModuleIssueLayout activeLayout={activeLayout} moduleId={moduleId?.toString()} />
</Row>
{/* peek overview */}
<IssuePeekOverview />
</div>
<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">
{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} />
</Row>
{/* peek overview */}
<IssuePeekOverview />
</div>
)}
</ProjectLevelWorkItemFiltersHOC>
</IssuesStoreContext.Provider>
);
});

View File

@ -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,21 +72,40 @@ export const ProjectLayoutRoot: FC = observer(() => {
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.PROJECT}>
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ProjectAppliedFiltersRoot />
<div className="relative h-full w-full overflow-auto bg-custom-background-90">
{/* mutation loader */}
{issues?.getIssueLoader() === "mutation" && (
<div className="fixed w-[40px] h-[40px] z-50 right-[20px] top-[70px] flex justify-center items-center bg-custom-background-80 shadow-sm rounded">
<Spinner className="w-4 h-4" />
<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">
{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" && (
<div className="fixed w-[40px] h-[40px] z-50 right-[20px] top-[70px] flex justify-center items-center bg-custom-background-80 shadow-sm rounded">
<Spinner className="w-4 h-4" />
</div>
)}
<ProjectIssueLayout activeLayout={activeLayout} />
</div>
)}
<ProjectIssueLayout activeLayout={activeLayout} />
</div>
{/* peek overview */}
<IssuePeekOverview />
</div>
{/* peek overview */}
<IssuePeekOverview />
</div>
)}
</ProjectLevelWorkItemFiltersHOC>
</IssuesStoreContext.Provider>
);
});

View File

@ -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}>
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ProjectViewAppliedFiltersRoot />
<div className="relative h-full w-full overflow-auto">
<ProjectViewIssueLayout activeLayout={activeLayout} viewId={viewId.toString()} />
</div>
{/* peek overview */}
<IssuePeekOverview />
</div>
<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">
{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>
);
});

View File

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

View File

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

View File

@ -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,23 +106,19 @@ 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 ?? {}}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFilterUpdate={handleDisplayFiltersUpdate}
issueIds={Array.isArray(issueIds) ? issueIds : []}
quickActions={renderQuickActions}
updateIssue={updateIssue}
canEditProperties={canEditProperties}
canLoadMoreIssues={!!nextPageResults}
loadMoreIssues={fetchNextPages}
isWorkspaceLevel
/>
{/* peek overview */}
<IssuePeekOverview />
</IssueLayoutHOC>
</IssuesStoreContext.Provider>
<IssueLayoutHOC layout={EIssueLayoutTypes.SPREADSHEET}>
<SpreadsheetView
displayProperties={issueFilters?.displayProperties ?? {}}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFilterUpdate={handleDisplayFiltersUpdate}
issueIds={Array.isArray(issueIds) ? issueIds : []}
quickActions={renderQuickActions}
updateIssue={updateIssue}
canEditProperties={canEditProperties}
canLoadMoreIssues={!!nextPageResults}
loadMoreIssues={fetchNextPages}
isWorkspaceLevel
/>
</IssueLayoutHOC>
);
});

View File

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

View File

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

View File

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

View File

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

View File

@ -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,22 +47,33 @@ export const ProfileIssuesPage = observer((props: Props) => {
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const activeLayout = issueFilters?.displayFilters?.layout || undefined;
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.PROFILE}>
<div className="flex flex-col h-full w-full">
<ProfileIssuesAppliedFiltersRoot />
<div className="-z-1 relative h-full w-full overflow-auto">
{activeLayout === "list" ? (
<ProfileIssuesListLayout />
) : activeLayout === "kanban" ? (
<ProfileIssuesKanBanLayout />
) : null}
</div>
</div>
{/* peek overview */}
<IssuePeekOverview />
<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">
{profileWorkItemsFilter && <WorkItemFiltersRow filter={profileWorkItemsFilter} />}
<div className="-z-1 relative h-full w-full overflow-auto">
{activeLayout === "list" ? (
<ProfileIssuesListLayout />
) : activeLayout === "kanban" ? (
<ProfileIssuesKanBanLayout />
) : null}
</div>
</div>
{/* peek overview */}
<IssuePeekOverview />
</>
)}
</WorkspaceLevelWorkItemFiltersHOC>
</IssuesStoreContext.Provider>
);
});

View File

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

View File

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

View File

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

View File

@ -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}
/>
</div>
)}
<div>
{/* 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">

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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