From 9aef5d4aa9d7ca76b731968506bcae44abc97e2e Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 19 Sep 2025 18:27:36 +0530 Subject: [PATCH] [WEB-4951] [WEB-4884] feat: work item filters revamp (#7810) --- apps/api/plane/app/views/cycle/base.py | 3 + apps/api/plane/app/views/cycle/issue.py | 56 ++- apps/api/plane/app/views/issue/archive.py | 58 +-- apps/api/plane/app/views/issue/base.py | 287 +++++++----- apps/api/plane/app/views/module/base.py | 3 + apps/api/plane/app/views/module/issue.py | 61 ++- apps/api/plane/app/views/view/base.py | 87 ++-- apps/api/plane/app/views/workspace/user.py | 79 +++- .../0107_migrate_filters_to_rich_filters.py | 74 +++ apps/api/plane/space/utils/grouper.py | 22 +- apps/api/plane/utils/filters/__init__.py | 10 + apps/api/plane/utils/filters/converters.py | 438 ++++++++++++++++++ .../api/plane/utils/filters/filter_backend.py | 380 +++++++++++++++ .../plane/utils/filters/filter_migrations.py | 146 ++++++ apps/api/plane/utils/filters/filterset.py | 180 +++++++ apps/api/plane/utils/grouper.py | 31 +- .../profile/[userId]/mobile-header.tsx | 65 +-- .../[projectId]/cycles/(detail)/header.tsx | 58 +-- .../cycles/(detail)/mobile-header.tsx | 104 +---- .../issues/(list)/mobile-header.tsx | 69 +-- .../[projectId]/modules/(detail)/header.tsx | 81 +--- .../modules/(detail)/mobile-header.tsx | 99 +--- .../views/(detail)/[viewId]/header.tsx | 69 +-- .../workspace-views/[globalViewId]/page.tsx | 10 +- .../(projects)/workspace-views/header.tsx | 54 +-- .../work-item-filters/project-level.ts | 16 + .../use-filters-operator-configs.ts | 15 + .../use-work-item-filters-config.tsx | 366 +++++++++++++++ .../components/archives/archive-tabs-list.tsx | 2 +- .../core/sidebar/progress-stats/assignee.tsx | 79 ++++ .../core/sidebar/progress-stats/label.tsx | 86 ++++ .../core/sidebar/progress-stats/shared.ts | 45 ++ .../sidebar/progress-stats/state_group.tsx | 46 ++ .../core/sidebar/single-progress-stats.tsx | 2 +- .../cycles/active-cycle/cycle-stats.tsx | 27 +- .../cycles/active-cycle/progress.tsx | 15 +- .../cycles/active-cycle/use-cycles-details.ts | 40 +- .../analytics-sidebar/issue-progress.tsx | 79 ++-- .../analytics-sidebar/progress-stats.tsx | 306 +++--------- .../issues/archived-issues-header.tsx | 78 +--- apps/web/core/components/issues/filters.tsx | 72 +-- .../sub-issues/display-filters.tsx | 1 - .../sub-issues/filters.tsx | 16 +- .../issue-detail-widgets/sub-issues/index.ts | 1 + .../sub-issues/title-actions.tsx | 14 +- .../issue-layouts/calendar/calendar.tsx | 11 +- .../calendar/dropdowns/options-dropdown.tsx | 14 +- .../issues/issue-layouts/calendar/header.tsx | 13 +- .../empty-states/archived-issues.tsx | 38 +- .../issue-layouts/empty-states/cycle.tsx | 44 +- .../issue-layouts/empty-states/index.tsx | 3 +- .../issue-layouts/empty-states/module.tsx | 42 +- .../empty-states/project-issues.tsx | 35 +- .../filters/applied-filters/filters-list.tsx | 166 ------- .../filters/applied-filters/index.ts | 2 - .../applied-filters/roots/archived-issue.tsx | 82 ---- .../applied-filters/roots/cycle-root.tsx | 105 ----- .../roots/global-view-root.tsx | 203 -------- .../filters/applied-filters/roots/index.ts | 7 - .../applied-filters/roots/module-root.tsx | 103 ---- .../roots/profile-issues-root.tsx | 78 ---- .../applied-filters/roots/project-root.tsx | 108 ----- .../roots/project-view-root.tsx | 162 ------- .../filters/applied-filters/state.tsx | 3 +- .../filters/header/display-filters/index.ts | 1 - .../header/display-filters/issue-grouping.tsx | 52 --- .../header/filters/filters-selection.tsx | 289 ------------ .../filters/header/filters/index.ts | 1 - .../list/headers/group-by-card.tsx | 4 +- .../quick-action-dropdowns/issue-detail.tsx | 2 +- .../quick-action-dropdowns/module-issue.tsx | 2 +- .../quick-action-dropdowns/project-issue.tsx | 4 +- .../roots/all-issue-layout-root.tsx | 142 +++--- .../roots/archived-issue-layout-root.tsx | 43 +- .../issue-layouts/roots/cycle-layout-root.tsx | 90 ++-- .../roots/module-layout-root.tsx | 54 ++- .../roots/project-layout-root.tsx | 71 ++- .../roots/project-view-layout-root.tsx | 84 +++- .../issues/issue-layouts/save-filter-view.tsx | 40 -- .../spreadsheet/columns/label-column.tsx | 2 +- .../spreadsheet/roots/workspace-root.tsx | 34 +- .../components/issues/issue-layouts/utils.tsx | 24 - .../analytics-sidebar/issue-progress.tsx | 82 ++-- .../analytics-sidebar/progress-stats.tsx | 308 +++--------- .../profile/profile-issues-filter.tsx | 71 +-- .../components/profile/profile-issues.tsx | 48 +- apps/web/core/components/profile/sidebar.tsx | 2 +- .../rich-filters/add-filters-button.tsx | 19 +- .../components/rich-filters/filters-row.tsx | 54 +-- apps/web/core/components/views/form.tsx | 188 +++----- apps/web/core/components/views/helper.tsx | 2 +- apps/web/core/components/views/modal.tsx | 14 +- .../views/update-view-component.tsx | 64 --- .../views/view-list-item-action.tsx | 8 +- .../work-item-filters/filters-hoc/base.tsx | 100 ++++ .../filters-hoc/project-level.tsx | 206 ++++++++ .../work-item-filters/filters-hoc/shared.ts | 30 ++ .../filters-hoc/workspace-level.tsx | 185 ++++++++ .../work-item-filters-row.tsx | 9 + .../workspace/sidebar/help-menu.tsx | 21 +- .../workspace/sidebar/help-section.tsx | 21 +- .../workspace/sidebar/help-section/root.tsx | 21 +- .../core/components/workspace/views/form.tsx | 168 ++----- .../core/components/workspace/views/modal.tsx | 20 +- .../workspace/views/quick-action.tsx | 2 +- .../workspace/views/view-list-item.tsx | 7 +- .../use-work-item-filter-instance.ts | 13 + .../use-work-item-filters.ts | 11 + apps/web/core/hooks/use-issues-actions.tsx | 70 +-- apps/web/core/store/global-view.store.ts | 45 +- .../core/store/issue/archived/filter.store.ts | 86 ++-- .../core/store/issue/cycle/filter.store.ts | 80 ++-- .../web/core/store/issue/cycle/issue.store.ts | 16 +- .../store/issue/helpers/base-issues-utils.ts | 6 +- .../store/issue/helpers/base-issues.store.ts | 3 +- .../helpers/issue-filter-helper.store.ts | 78 ++-- .../issue/issue-details/sub_issues.store.ts | 1 - .../issue-details/sub_issues_filter.store.ts | 13 +- .../core/store/issue/module/filter.store.ts | 132 +++--- .../core/store/issue/profile/filter.store.ts | 97 ++-- .../core/store/issue/profile/issue.store.ts | 16 +- .../store/issue/project-views/filter.store.ts | 159 ++++--- .../core/store/issue/project/filter.store.ts | 121 ++--- .../issue/workspace-draft/filter.store.ts | 75 +-- .../store/issue/workspace/filter.store.ts | 115 ++--- apps/web/core/store/label.store.ts | 23 +- apps/web/core/store/root.store.ts | 4 + apps/web/core/store/user/settings.store.ts | 39 +- packages/constants/src/issue/filter.ts | 402 ++++++++-------- packages/shared-state/src/store/index.ts | 1 + .../src/store/rich-filters/config.ts | 24 +- .../src/store/work-item-filters/adapter.ts | 259 +++++++++++ .../store/work-item-filters/filter.store.ts | 215 +++++++++ .../src/store/work-item-filters/index.ts | 2 + packages/shared-state/src/utils/index.ts | 1 + .../src/utils/work-item-filters.helper.ts | 32 ++ packages/types/src/issues.ts | 2 - .../src/rich-filters/field-types/shared.ts | 1 + .../types/src/rich-filters/operators/core.ts | 15 +- .../src/rich-filters/operators/extended.ts | 19 +- packages/types/src/view-props.ts | 59 ++- packages/types/src/views.ts | 13 +- packages/types/src/workspace-views.ts | 8 +- packages/utils/src/filter.ts | 16 - packages/utils/src/index.ts | 1 + .../rich-filters/factories/configs/core.ts | 37 +- .../rich-filters/factories/configs/shared.ts | 92 ++-- .../configs/filters/cycle.ts | 70 +++ .../work-item-filters/configs/filters/date.ts | 43 ++ .../configs/filters/index.ts | 8 + .../configs/filters/label.ts | 69 +++ .../configs/filters/module.ts | 63 +++ .../configs/filters/priority.ts | 66 +++ .../configs/filters/project.ts | 28 ++ .../configs/filters/shared.ts | 64 +++ .../configs/filters/state.ts | 127 +++++ .../work-item-filters/configs/filters/user.ts | 156 +++++++ .../src/work-item-filters/configs/index.ts | 1 + packages/utils/src/work-item-filters/index.ts | 1 + packages/utils/src/work-item/base.ts | 24 +- 160 files changed, 5879 insertions(+), 4881 deletions(-) create mode 100644 apps/api/plane/db/migrations/0107_migrate_filters_to_rich_filters.py create mode 100644 apps/api/plane/utils/filters/__init__.py create mode 100644 apps/api/plane/utils/filters/converters.py create mode 100644 apps/api/plane/utils/filters/filter_backend.py create mode 100644 apps/api/plane/utils/filters/filter_migrations.py create mode 100644 apps/api/plane/utils/filters/filterset.py create mode 100644 apps/web/ce/helpers/work-item-filters/project-level.ts create mode 100644 apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts create mode 100644 apps/web/ce/hooks/work-item-filters/use-work-item-filters-config.tsx create mode 100644 apps/web/core/components/core/sidebar/progress-stats/assignee.tsx create mode 100644 apps/web/core/components/core/sidebar/progress-stats/label.tsx create mode 100644 apps/web/core/components/core/sidebar/progress-stats/shared.ts create mode 100644 apps/web/core/components/core/sidebar/progress-stats/state_group.tsx delete mode 100644 apps/web/core/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx delete mode 100644 apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/archived-issue.tsx delete mode 100644 apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx delete mode 100644 apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx delete mode 100644 apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/index.ts delete mode 100644 apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx delete mode 100644 apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/profile-issues-root.tsx delete mode 100644 apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx delete mode 100644 apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx delete mode 100644 apps/web/core/components/issues/issue-layouts/filters/header/display-filters/issue-grouping.tsx delete mode 100644 apps/web/core/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx delete mode 100644 apps/web/core/components/issues/issue-layouts/save-filter-view.tsx delete mode 100644 apps/web/core/components/views/update-view-component.tsx create mode 100644 apps/web/core/components/work-item-filters/filters-hoc/base.tsx create mode 100644 apps/web/core/components/work-item-filters/filters-hoc/project-level.tsx create mode 100644 apps/web/core/components/work-item-filters/filters-hoc/shared.ts create mode 100644 apps/web/core/components/work-item-filters/filters-hoc/workspace-level.tsx create mode 100644 apps/web/core/components/work-item-filters/work-item-filters-row.tsx create mode 100644 apps/web/core/hooks/store/work-item-filters/use-work-item-filter-instance.ts create mode 100644 apps/web/core/hooks/store/work-item-filters/use-work-item-filters.ts create mode 100644 packages/shared-state/src/store/work-item-filters/adapter.ts create mode 100644 packages/shared-state/src/store/work-item-filters/filter.store.ts create mode 100644 packages/shared-state/src/store/work-item-filters/index.ts create mode 100644 packages/shared-state/src/utils/work-item-filters.helper.ts create mode 100644 packages/utils/src/work-item-filters/configs/filters/cycle.ts create mode 100644 packages/utils/src/work-item-filters/configs/filters/date.ts create mode 100644 packages/utils/src/work-item-filters/configs/filters/index.ts create mode 100644 packages/utils/src/work-item-filters/configs/filters/label.ts create mode 100644 packages/utils/src/work-item-filters/configs/filters/module.ts create mode 100644 packages/utils/src/work-item-filters/configs/filters/priority.ts create mode 100644 packages/utils/src/work-item-filters/configs/filters/project.ts create mode 100644 packages/utils/src/work-item-filters/configs/filters/shared.ts create mode 100644 packages/utils/src/work-item-filters/configs/filters/state.ts create mode 100644 packages/utils/src/work-item-filters/configs/filters/user.ts create mode 100644 packages/utils/src/work-item-filters/configs/index.ts create mode 100644 packages/utils/src/work-item-filters/index.ts diff --git a/apps/api/plane/app/views/cycle/base.py b/apps/api/plane/app/views/cycle/base.py index cb07d76c4..a9a651c6d 100644 --- a/apps/api/plane/app/views/cycle/base.py +++ b/apps/api/plane/app/views/cycle/base.py @@ -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 ) diff --git a/apps/api/plane/app/views/cycle/issue.py b/apps/api/plane/app/views/cycle/issue.py index ad7762629..0e143b58f 100644 --- a/apps/api/plane/app/views/cycle/issue.py +++ b/apps/api/plane/app/views/cycle/issue.py @@ -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 ), diff --git a/apps/api/plane/app/views/issue/archive.py b/apps/api/plane/app/views/issue/archive.py index 122f4bdc8..9cd69d905 100644 --- a/apps/api/plane/app/views/issue/archive.py +++ b/apps/api/plane/app/views/issue/archive.py @@ -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 diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 4d0d4457e..62ea3d295 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -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, diff --git a/apps/api/plane/app/views/module/base.py b/apps/api/plane/app/views/module/base.py index db64cdf81..522a7eaa0 100644 --- a/apps/api/plane/app/views/module/base.py +++ b/apps/api/plane/app/views/module/base.py @@ -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 ) diff --git a/apps/api/plane/app/views/module/issue.py b/apps/api/plane/app/views/module/issue.py index 96d1f550a..e18d61a4f 100644 --- a/apps/api/plane/app/views/module/issue.py +++ b/apps/api/plane/app/views/module/issue.py @@ -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()), diff --git a/apps/api/plane/app/views/view/base.py b/apps/api/plane/app/views/view/base.py index c1dd2631d..90196500c 100644 --- a/apps/api/plane/app/views/view/base.py +++ b/apps/api/plane/app/views/view/base.py @@ -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, ) diff --git a/apps/api/plane/app/views/workspace/user.py b/apps/api/plane/app/views/workspace/user.py index cc1caa92c..72233e151 100644 --- a/apps/api/plane/app/views/workspace/user.py +++ b/apps/api/plane/app/views/workspace/user.py @@ -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 ) diff --git a/apps/api/plane/db/migrations/0107_migrate_filters_to_rich_filters.py b/apps/api/plane/db/migrations/0107_migrate_filters_to_rich_filters.py new file mode 100644 index 000000000..3048dd86f --- /dev/null +++ b/apps/api/plane/db/migrations/0107_migrate_filters_to_rich_filters.py @@ -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, + ), + ] \ No newline at end of file diff --git a/apps/api/plane/space/utils/grouper.py b/apps/api/plane/space/utils/grouper.py index 4dd956b9f..9e18f172e 100644 --- a/apps/api/plane/space/utils/grouper.py +++ b/apps/api/plane/space/utils/grouper.py @@ -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: diff --git a/apps/api/plane/utils/filters/__init__.py b/apps/api/plane/utils/filters/__init__.py new file mode 100644 index 000000000..48a85829e --- /dev/null +++ b/apps/api/plane/utils/filters/__init__.py @@ -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"] diff --git a/apps/api/plane/utils/filters/converters.py b/apps/api/plane/utils/filters/converters.py new file mode 100644 index 000000000..13a5221b8 --- /dev/null +++ b/apps/api/plane/utils/filters/converters.py @@ -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} diff --git a/apps/api/plane/utils/filters/filter_backend.py b/apps/api/plane/utils/filters/filter_backend.py new file mode 100644 index 000000000..d9d8429e9 --- /dev/null +++ b/apps/api/plane/utils/filters/filter_backend.py @@ -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 '__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)) diff --git a/apps/api/plane/utils/filters/filter_migrations.py b/apps/api/plane/utils/filters/filter_migrations.py new file mode 100644 index 000000000..baf31c432 --- /dev/null +++ b/apps/api/plane/utils/filters/filter_migrations.py @@ -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 diff --git a/apps/api/plane/utils/filters/filterset.py b/apps/api/plane/utils/filters/filterset.py new file mode 100644 index 000000000..761916ac7 --- /dev/null +++ b/apps/api/plane/utils/filters/filterset.py @@ -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, + ) diff --git a/apps/api/plane/utils/grouper.py b/apps/api/plane/utils/grouper.py index d69a1f583..05b78da0b 100644 --- a/apps/api/plane/utils/grouper.py +++ b/apps/api/plane/utils/grouper.py @@ -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 [] diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx index 69c8ea3cd..ce84d832a 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx @@ -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) => { if (!workspaceSlug || !userId) return; @@ -145,32 +110,6 @@ export const ProfileIssuesMobileHeader = observer(() => { ); })} -
- - {t("common.filters")} - -
- } - isFiltersApplied={isIssueFilterActive(issueFilters)} - > - - -
{ > { 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) => { if (!workspaceSlug || !projectId) return; @@ -239,27 +208,6 @@ export const CycleIssuesHeader: React.FC = observer(() => { activeLayout={activeLayout} />
- } - > - - { > { - // 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) => { 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) => ( { @@ -155,34 +115,6 @@ export const CycleIssuesMobileHeader = () => { ))} -
- - {t("common.filters")} - - - } - isFiltersApplied={isIssueFilterActive(issueFilters)} - > - - -
{ > { // 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) => { if (!workspaceSlug || !projectId) return; @@ -108,34 +71,6 @@ export const ProjectIssuesMobileHeader = observer(() => { layouts={[EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN, EIssueLayoutTypes.CALENDAR]} onChange={handleLayoutChange} /> -
- - {t("common.filters")} - - - } - isFiltersApplied={isIssueFilterActive(issueFilters)} - > - - -
{ > { 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) => { 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} />
- } - > - - { > { - 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) => { 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) => ( { @@ -131,34 +100,6 @@ export const ModuleIssuesMobileHeader = observer(() => { ))} -
- - Filters - - - } - isFiltersApplied={isIssueFilterActive(issueFilters)} - > - - -
{ > { 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) => { if (!workspaceSlug || !projectId || !viewId) return; @@ -217,33 +175,10 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { onChange={(layout) => handleLayoutChange(layout)} selectedLayout={activeLayout} /> - - - - { return ( <> -
-
- {globalViewId && ( - - )} - -
-
+ ); }); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx index b4fa1bbd9..68e954121 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx @@ -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) => { 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()} /> - - - TWorkItemFiltersEntityProps; + +export const getAdditionalProjectLevelFiltersHOCProps: TGetAdditionalPropsForProjectLevelFiltersHOC = ({ + workspaceSlug, +}) => ({ + workspaceSlug, +}); diff --git a/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts b/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts new file mode 100644 index 000000000..636abce76 --- /dev/null +++ b/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts @@ -0,0 +1,15 @@ +import { CORE_OPERATORS, TSupportedOperators } from "@plane/types"; + +export type TFiltersOperatorConfigs = { + allowedOperators: Set; + allowNegative: boolean; +}; + +export type TUseFiltersOperatorConfigsProps = { + workspaceSlug: string; +}; + +export const useFiltersOperatorConfigs = (_props: TUseFiltersOperatorConfigsProps): TFiltersOperatorConfigs => ({ + allowedOperators: new Set(Object.values(CORE_OPERATORS)), + allowNegative: false, +}); diff --git a/apps/web/ce/hooks/work-item-filters/use-work-item-filters-config.tsx b/apps/web/ce/hooks/work-item-filters/use-work-item-filters-config.tsx new file mode 100644 index 000000000..e56657e12 --- /dev/null +++ b/apps/web/ce/hooks/work-item-filters/use-work-item-filters-config.tsx @@ -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[]; + configMap: { + [key in TWorkItemFilterProperty]?: TFilterConfig; + }; + 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("state_group")({ + isEnabled: isFilterEnabled("state_group"), + filterIcon: DoubleCircleIcon, + getOptionIcon: (stateGroupKey) => , + ...operatorConfigs, + }), + [isFilterEnabled, operatorConfigs] + ); + + // state filter config + const stateFilterConfig = useMemo( + () => + getStateFilterConfig("state_id")({ + isEnabled: isFilterEnabled("state_id") && workItemStates !== undefined, + filterIcon: DoubleCircleIcon, + getOptionIcon: (state) => , + states: workItemStates ?? [], + ...operatorConfigs, + }), + [isFilterEnabled, workItemStates, operatorConfigs] + ); + + // label filter config + const labelFilterConfig = useMemo( + () => + getLabelFilterConfig("label_id")({ + isEnabled: isFilterEnabled("label_id") && workItemLabels !== undefined, + filterIcon: Tag, + labels: workItemLabels ?? [], + getOptionIcon: (color) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, workItemLabels, operatorConfigs] + ); + + // cycle filter config + const cycleFilterConfig = useMemo( + () => + getCycleFilterConfig("cycle_id")({ + isEnabled: isFilterEnabled("cycle_id") && project?.cycle_view === true && cycles !== undefined, + filterIcon: ContrastIcon, + getOptionIcon: (cycleGroup) => , + cycles: cycles ?? [], + ...operatorConfigs, + }), + [isFilterEnabled, project?.cycle_view, cycles, operatorConfigs] + ); + + // module filter config + const moduleFilterConfig = useMemo( + () => + getModuleFilterConfig("module_id")({ + isEnabled: isFilterEnabled("module_id") && project?.module_view === true && modules !== undefined, + filterIcon: DiceIcon, + getOptionIcon: () => , + modules: modules ?? [], + ...operatorConfigs, + }), + [isFilterEnabled, project?.module_view, modules, operatorConfigs] + ); + + // assignee filter config + const assigneeFilterConfig = useMemo( + () => + getAssigneeFilterConfig("assignee_id")({ + isEnabled: isFilterEnabled("assignee_id") && members !== undefined, + filterIcon: Users, + members: members ?? [], + getOptionIcon: (memberDetails) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, members, operatorConfigs] + ); + + // mention filter config + const mentionFilterConfig = useMemo( + () => + getMentionFilterConfig("mention_id")({ + isEnabled: isFilterEnabled("mention_id") && members !== undefined, + filterIcon: AtSign, + members: members ?? [], + getOptionIcon: (memberDetails) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, members, operatorConfigs] + ); + + // created by filter config + const createdByFilterConfig = useMemo( + () => + getCreatedByFilterConfig("created_by_id")({ + isEnabled: isFilterEnabled("created_by_id") && members !== undefined, + filterIcon: CircleUserRound, + members: members ?? [], + getOptionIcon: (memberDetails) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, members, operatorConfigs] + ); + + // subscriber filter config + const subscriberFilterConfig = useMemo( + () => + getSubscriberFilterConfig("subscriber_id")({ + isEnabled: isFilterEnabled("subscriber_id") && members !== undefined, + filterIcon: Users, + members: members ?? [], + getOptionIcon: (memberDetails) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, members, operatorConfigs] + ); + + // priority filter config + const priorityFilterConfig = useMemo( + () => + getPriorityFilterConfig("priority")({ + isEnabled: isFilterEnabled("priority"), + filterIcon: SignalHigh, + getOptionIcon: (priority) => , + ...operatorConfigs, + }), + [isFilterEnabled, operatorConfigs] + ); + + // start date filter config + const startDateFilterConfig = useMemo( + () => + getStartDateFilterConfig("start_date")({ + isEnabled: true, + filterIcon: CalendarClock, + ...operatorConfigs, + }), + [operatorConfigs] + ); + + // target date filter config + const targetDateFilterConfig = useMemo( + () => + getTargetDateFilterConfig("target_date")({ + isEnabled: true, + filterIcon: CalendarCheck2, + ...operatorConfigs, + }), + [operatorConfigs] + ); + + // project filter config + const projectFilterConfig = useMemo( + () => + getProjectFilterConfig("project_id")({ + isEnabled: isFilterEnabled("project_id") && projects !== undefined, + filterIcon: Briefcase, + projects: projects, + getOptionIcon: (project) => , + ...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, + }; +}; diff --git a/apps/web/core/components/archives/archive-tabs-list.tsx b/apps/web/core/components/archives/archive-tabs-list.tsx index d68144691..432fac68d 100644 --- a/apps/web/core/components/archives/archive-tabs-list.tsx +++ b/apps/web/core/components/archives/archive-tabs-list.tsx @@ -48,7 +48,7 @@ export const ArchiveTabsList: FC = observer(() => { tab.shouldRender(projectDetails) && ( void; + distribution: TAssigneeData; + isEditable?: boolean; +}; + +export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => { + const { distribution, isEditable, selectedAssigneeIds, handleAssigneeFiltersUpdate } = props; + const { t } = useTranslation(); + return ( +
+ {distribution && distribution.length > 0 ? ( + distribution.map((assignee, index) => { + if (assignee?.id) + return ( + + + {assignee?.title ?? ""} +
+ } + completed={assignee?.completed ?? 0} + total={assignee?.total ?? 0} + {...(isEditable && { + onClick: () => handleAssigneeFiltersUpdate(assignee.id), + selected: assignee.id ? selectedAssigneeIds.includes(assignee.id) : false, + })} + /> + ); + else + return ( + +
+ User +
+ {t("no_assignee")} +
+ } + completed={assignee?.completed ?? 0} + total={assignee?.total ?? 0} + /> + ); + }) + ) : ( +
+
+ empty members +
+
{t("no_assignee")}
+
+ )} +
+ ); +}); diff --git a/apps/web/core/components/core/sidebar/progress-stats/label.tsx b/apps/web/core/components/core/sidebar/progress-stats/label.tsx new file mode 100644 index 000000000..3897ec13e --- /dev/null +++ b/apps/web/core/components/core/sidebar/progress-stats/label.tsx @@ -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 ( +
+ {distribution && distribution.length > 0 ? ( + distribution.map((label, index) => { + if (label.id) { + return ( + + + {label.title ?? t("no_labels_yet")} +
+ } + completed={label.completed} + total={label.total} + {...(isEditable && { + onClick: () => handleLabelFiltersUpdate(label.id), + selected: label.id ? selectedLabelIds.includes(label.id) : false, + })} + /> + ); + } else { + return ( + + + {label.title ?? t("no_labels_yet")} + + } + completed={label.completed} + total={label.total} + /> + ); + } + }) + ) : ( +
+
+ empty label +
+
{t("no_labels_yet")}
+
+ )} + + ); +}); diff --git a/apps/web/core/components/core/sidebar/progress-stats/shared.ts b/apps/web/core/components/core/sidebar/progress-stats/shared.ts new file mode 100644 index 000000000..b0b549cd5 --- /dev/null +++ b/apps/web/core/components/core/sidebar/progress-stats/shared.ts @@ -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; + +export type TSelectedFilterProgressStats = { + assignees: TSelectedFilterProgressStatsType | undefined; + labels: TSelectedFilterProgressStatsType | undefined; + stateGroups: TSelectedFilterProgressStatsType | undefined; +}; + +export const createFilterUpdateHandler = + ( + 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 }); + }; diff --git a/apps/web/core/components/core/sidebar/progress-stats/state_group.tsx b/apps/web/core/components/core/sidebar/progress-stats/state_group.tsx new file mode 100644 index 000000000..8aee13440 --- /dev/null +++ b/apps/web/core/components/core/sidebar/progress-stats/state_group.tsx @@ -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 ( +
+ {distribution.map((group, index) => ( + + + {group.state} +
+ } + completed={group.completed} + total={totalIssuesCount} + {...(isEditable && { + onClick: () => group.state && handleStateGroupFiltersUpdate(group.state), + selected: group.state ? selectedStateGroups.includes(group.state) : false, + })} + /> + ))} + + ); +}); diff --git a/apps/web/core/components/core/sidebar/single-progress-stats.tsx b/apps/web/core/components/core/sidebar/single-progress-stats.tsx index 047d8bc04..a0443f06e 100644 --- a/apps/web/core/components/core/sidebar/single-progress-stats.tsx +++ b/apps/web/core/components/core/sidebar/single-progress-stats.tsx @@ -18,7 +18,7 @@ export const SingleProgressStats: React.FC = ({
{title}
diff --git a/apps/web/core/components/cycles/active-cycle/cycle-stats.tsx b/apps/web/core/components/cycles/active-cycle/cycle-stats.tsx index e0f71dc3d..0a8bfa3ff 100644 --- a/apps/web/core/components/cycles/active-cycle/cycle-stats.tsx +++ b/apps/web/core/components/cycles/active-cycle/cycle-stats.tsx @@ -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 = 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 = 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 = 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 + } /> )) ) : ( diff --git a/apps/web/core/components/cycles/active-cycle/progress.tsx b/apps/web/core/components/cycles/active-cycle/progress.tsx index b7fadd903..db1a7721b 100644 --- a/apps/web/core/components/cycles/active-cycle/progress.tsx +++ b/apps/web/core/components/cycles/active-cycle/progress.tsx @@ -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 = 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 = observer((props
{ - if (groupedProjectStates) { - const states = groupedProjectStates[group].map((state) => state.id); - handleFiltersUpdate("state", states, true); - } + handleFiltersUpdate([{ property: "state_group", operator: "in", value: [group] }]); }} >
diff --git a/apps/web/core/components/cycles/active-cycle/use-cycles-details.ts b/apps/web/core/components/cycles/active-cycle/use-cycles-details.ts index 74ea52e7d..54a3b506e 100644 --- a/apps/web/core/components/cycles/active-cycle/use-cycles-details.ts +++ b/apps/web/core/components/cycles/active-cycle/use-cycles-details.ts @@ -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, diff --git a/apps/web/core/components/cycles/analytics-sidebar/issue-progress.tsx b/apps/web/core/components/cycles/analytics-sidebar/issue-progress.tsx index 70ed49f4c..fbea2e7be 100644 --- a/apps/web/core/components/cycles/analytics-sidebar/issue-progress.tsx +++ b/apps/web/core/components/cycles/analytics-sidebar/issue-progress.tsx @@ -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 = 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 = 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 (
@@ -159,7 +125,6 @@ export const CycleAnalyticsProgress: FC = observer((pro
)} - {cycleStartDate && cycleEndDate ? ( @@ -172,16 +137,24 @@ export const CycleAnalyticsProgress: FC = observer((pro
)} diff --git a/apps/web/core/components/cycles/analytics-sidebar/progress-stats.tsx b/apps/web/core/components/cycles/analytics-sidebar/progress-stats.tsx index 65a1cd10f..eaa60b6bd 100644 --- a/apps/web/core/components/cycles/analytics-sidebar/progress-stats.tsx +++ b/apps/web/core/components/cycles/analytics-sidebar/progress-stats.tsx @@ -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 ( -
- {distribution && distribution.length > 0 ? ( - distribution.map((assignee, index) => { - if (assignee?.id) - return ( - - - {assignee?.title ?? ""} -
- } - completed={assignee?.completed ?? 0} - total={assignee?.total ?? 0} - {...(isEditable && { - onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""), - selected: filters?.filters?.assignees?.includes(assignee.id ?? ""), - })} - /> - ); - else - return ( - -
- User -
- {t("no_assignee")} -
- } - completed={assignee?.completed ?? 0} - total={assignee?.total ?? 0} - /> - ); - }) - ) : ( -
-
- empty members -
-
{t("no_assignee")}
-
- )} -
- ); -}); - -export const LabelStatComponent = observer((props: TLabelStatComponent) => { - const { distribution, isEditable, filters, handleFiltersUpdate } = props; - const { t } = useTranslation(); - return ( -
- {distribution && distribution.length > 0 ? ( - distribution.map((label, index) => { - if (label.id) { - return ( - - - {label.title ?? t("no_labels_yet")} -
- } - completed={label.completed} - total={label.total} - {...(isEditable && { - onClick: () => handleFiltersUpdate("labels", label.id ?? ""), - selected: filters?.filters?.labels?.includes(label.id ?? `no-label-${index}`), - })} - /> - ); - } else { - return ( - - - {label.title ?? t("no_labels_yet")} - - } - completed={label.completed} - total={label.total} - /> - ); - } - }) - ) : ( -
-
- empty label -
-
{t("no_labels_yet")}
-
- )} - - ); -}); - -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 ( -
- {distribution.map((group, index) => ( - - - {group.state} -
- } - completed={group.completed} - total={totalIssuesCount} - {...(isEditable && { - onClick: () => group.state && handleFiltersUpdate("state", getStateGroupState(group.state) ?? []), - })} - /> - ))} - - ); -}); - -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; - 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 = 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 = 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 (
@@ -329,7 +128,7 @@ export const CycleProgressStats: FC = observer((props) => { size === "xs" ? `text-xs` : `text-sm` )} > - {progressStats.map((stat) => ( + {PROGRESS_STATS.map((stat) => ( = observer((props) => { - diff --git a/apps/web/core/components/issues/archived-issues-header.tsx b/apps/web/core/components/issues/archived-issues-header.tsx index 4bbc63a23..b8cdc52df 100644 --- a/apps/web/core/components/issues/archived-issues-header.tsx +++ b/apps/web/core/components/issues/archived-issues-header.tsx @@ -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) => { if (!workspaceSlug || !projectId) return; @@ -77,42 +42,25 @@ export const ArchivedIssuesHeader: FC = observer(() => { }; return ( -
-
+
+ -
- {/* filter options */} -
- - - + + -
-
+ + ); }); diff --git a/apps/web/core/components/issues/filters.tsx b/apps/web/core/components/issues/filters.tsx index 4340b41f1..320d4481c 100644 --- a/apps/web/core/components/issues/filters.tsx +++ b/apps/web/core/components/issues/filters.tsx @@ -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} />
- } - > - - } title={t("common.display")} diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx index dfb246d54..aa43c7759 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx @@ -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; diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx index bf76004f9..86270dd72 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx @@ -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 = 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 ( <> diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts index 5fb3c6334..0bcdfe96a 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts @@ -3,3 +3,4 @@ export * from "./title"; export * from "./root"; export * from "./quick-action-button"; export * from "./display-filters"; +export * from "./content"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx index 9cf0d8d5e..84c21900f 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx @@ -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 = 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) => { @@ -72,7 +75,6 @@ export const SubWorkItemTitleActions: FC = 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 = observ filters={subIssueFilters?.filters ?? {}} memberIds={projectMemberIds ?? undefined} states={projectStates} - layoutDisplayFiltersOptions={layoutDisplayFiltersOptions} + availableFilters={SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE} /> {!disabled && ( diff --git a/apps/web/core/components/issues/issue-layouts/calendar/calendar.tsx b/apps/web/core/components/issues/issue-layouts/calendar/calendar.tsx index d0d1d575c..9a387743c 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/calendar.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/calendar.tsx @@ -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; canEditProperties: (projectId: string | undefined) => boolean; isEpic?: boolean; diff --git a/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx b/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx index 469f61254..da3322683 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx @@ -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; } diff --git a/apps/web/core/components/issues/issue-layouts/calendar/header.tsx b/apps/web/core/components/issues/issue-layouts/calendar/header.tsx index 55da82d03..7622fe61f 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/header.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/header.tsx @@ -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; setSelectedDate: (date: Date) => void; } diff --git a/apps/web/core/components/issues/issue-layouts/empty-states/archived-issues.tsx b/apps/web/core/components/issues/issue-layouts/empty-states/archived-issues.tsx index cc10b4690..a54aff362 100644 --- a/apps/web/core/components/issues/issue-layouts/empty-states/archived-issues.tsx +++ b/apps/web/core/components/issues/issue-layouts/empty-states/archived-issues.tsx @@ -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 (
- {issueFilterCount > 0 ? ( + {archivedWorkItemFilter?.hasActiveFilters ? ( ) : ( diff --git a/apps/web/core/components/issues/issue-layouts/empty-states/cycle.tsx b/apps/web/core/components/issues/issue-layouts/empty-states/cycle.tsx index ed0fe8512..24b65acff 100644 --- a/apps/web/core/components/issues/issue-layouts/empty-states/cycle.tsx +++ b/apps/web/core/components/issues/issue-layouts/empty-states/cycle.tsx @@ -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 (
{ description={t("project_cycles.empty_state.completed_no_issues.description")} assetPath={completedNoIssuesResolvedPath} /> - ) : isEmptyFilters ? ( + ) : cycleWorkItemFilter?.hasActiveFilters ? ( ) : ( diff --git a/apps/web/core/components/issues/issue-layouts/empty-states/index.tsx b/apps/web/core/components/issues/issue-layouts/empty-states/index.tsx index ae23acb57..0dd8d3c39 100644 --- a/apps/web/core/components/issues/issue-layouts/empty-states/index.tsx +++ b/apps/web/core/components/issues/issue-layouts/empty-states/index.tsx @@ -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"; diff --git a/apps/web/core/components/issues/issue-layouts/empty-states/module.tsx b/apps/web/core/components/issues/issue-layouts/empty-states/module.tsx index 20b26accf..69c224f9f 100644 --- a/apps/web/core/components/issues/issue-layouts/empty-states/module.tsx +++ b/apps/web/core/components/issues/issue-layouts/empty-states/module.tsx @@ -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 (
{ handleOnSubmit={handleAddIssuesToModule} />
- {isEmptyFilters ? ( + {moduleWorkItemFilter?.hasActiveFilters ? ( ) : ( diff --git a/apps/web/core/components/issues/issue-layouts/empty-states/project-issues.tsx b/apps/web/core/components/issues/issue-layouts/empty-states/project-issues.tsx index fdec45f78..c8a776850 100644 --- a/apps/web/core/components/issues/issue-layouts/empty-states/project-issues.tsx +++ b/apps/web/core/components/issues/issue-layouts/empty-states/project-issues.tsx @@ -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 (
- {issueFilterCount > 0 ? ( + {projectWorkItemFilter?.hasActiveFilters ? ( ) : ( diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx deleted file mode 100644 index 9918ce415..000000000 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx +++ /dev/null @@ -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 = 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 ( -
- {Object.entries(appliedFilters).map(([key, value]) => { - const filterKey = key as keyof IIssueFilterOptions; - - if (!value) return; - if (Array.isArray(value) && value.length === 0) return; - - return ( - - {replaceUnderscoreIfSnakeCase(filterKey)} - {membersFilters.includes(filterKey) && ( - handleRemoveFilter(filterKey, val)} - values={value} - /> - )} - {dateFilters.includes(filterKey) && ( - handleRemoveFilter(filterKey, val)} values={value} /> - )} - {filterKey === "labels" && ( - handleRemoveFilter("labels", val)} - labels={labels} - values={value} - /> - )} - {filterKey === "priority" && ( - handleRemoveFilter("priority", val)} - values={value} - /> - )} - {filterKey === "state" && states && ( - handleRemoveFilter("state", val)} - states={states} - values={value} - /> - )} - {filterKey === "state_group" && ( - handleRemoveFilter("state_group", val)} values={value} /> - )} - {filterKey === "project" && ( - handleRemoveFilter("project", val)} - values={value} - /> - )} - {filterKey === "cycle" && ( - handleRemoveFilter("cycle", val)} - values={value} - /> - )} - {filterKey === "module" && ( - handleRemoveFilter("module", val)} - values={value} - /> - )} - {filterKey === "issue_type" && ( - handleRemoveFilter("issue_type", val)} - values={value} - /> - )} - {filterKey === "team_project" && ( - handleRemoveFilter("team_project", val)} - values={value} - /> - )} - {isEditingAllowed && ( - - )} - - ); - })} - {isEditingAllowed && ( - - )} -
- ); -}); diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/index.ts b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/index.ts index ba074e84f..b2b7ab67b 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/index.ts +++ b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/index.ts @@ -1,6 +1,4 @@ -export * from "./roots"; export * from "./date"; -export * from "./filters-list"; export * from "./label"; export * from "./members"; export * from "./priority"; diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/archived-issue.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/archived-issue.tsx deleted file mode 100644 index de6032b74..000000000 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/archived-issue.tsx +++ /dev/null @@ -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 ( -
- -
- ); -}); diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx deleted file mode 100644 index bfc34f18b..000000000 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/cycle-root.tsx +++ /dev/null @@ -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 ( -
- - - - -
- ); -}); diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx deleted file mode 100644 index 8ecf75395..000000000 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx +++ /dev/null @@ -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 ( -
- setIsModalOpen(false)} - preLoadedData={{ - name: `${viewDetails?.name} 2`, - description: viewDetails?.description, - access: viewDetails?.access ?? EViewAccess.PUBLIC, - ...viewFilters, - }} - /> - - {isLoading ? ( - - - - - - ) : ( - - )} - - {!isDefaultView ? ( - - ) : ( - <> - )} -
- ); -}); diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/index.ts b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/index.ts deleted file mode 100644 index 6e14c24bc..000000000 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/index.ts +++ /dev/null @@ -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"; diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx deleted file mode 100644 index 013942646..000000000 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx +++ /dev/null @@ -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 ( -
- - - - -
- ); -}); diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/profile-issues-root.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/profile-issues-root.tsx deleted file mode 100644 index cfadd27e2..000000000 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/profile-issues-root.tsx +++ /dev/null @@ -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 ( -
- -
- ); -}); diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx deleted file mode 100644 index 010492274..000000000 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx +++ /dev/null @@ -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 = 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 ( -
- - - - - {isEditingAllowed && storeType === EIssuesStoreType.PROJECT && ( - - )} - -
- ); -}); diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx deleted file mode 100644 index 7554e35ca..000000000 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-view-root.tsx +++ /dev/null @@ -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 ( -
- 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, - }} - /> - - - - - - -
- ); -}); diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/state.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/state.tsx index f55aca434..c04713d65 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/state.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/state.tsx @@ -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; diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/index.ts b/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/index.ts index e6dff8799..07b50f85e 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/index.ts +++ b/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/index.ts @@ -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"; diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/issue-grouping.tsx b/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/issue-grouping.tsx deleted file mode 100644 index c01378242..000000000 --- a/apps/web/core/components/issues/issue-layouts/filters/header/display-filters/issue-grouping.tsx +++ /dev/null @@ -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 = observer((props) => { - const { selectedIssueType, handleUpdate, isEpic = false } = props; - - const [previewEnabled, setPreviewEnabled] = React.useState(true); - - const activeIssueType = selectedIssueType ?? null; - - return ( - <> - setPreviewEnabled(!previewEnabled)} - /> - {previewEnabled && ( -
- {ISSUE_FILTER_OPTIONS.map((issueType) => ( - handleUpdate(issueType?.key)} - title={`${issueType.title} ${isEpic ? "Epics" : "Work items"}`} - multiple={false} - /> - ))} -
- )} - - ); -}); diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx b/apps/web/core/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx deleted file mode 100644 index 04afbba80..000000000 --- a/apps/web/core/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx +++ /dev/null @@ -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) => 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 = 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 ( -
-
-
- - setFiltersSearchQuery(e.target.value)} - autoFocus={!isMobile} - /> - {filtersSearchQuery !== "" && ( - - )} -
-
-
- {/* priority */} - {isFilterEnabled("priority") && ( -
- handleFiltersUpdate("priority", val)} - searchQuery={filtersSearchQuery} - /> -
- )} - - {/* state group */} - {isFilterEnabled("state_group") && ( -
- handleFiltersUpdate("state_group", val)} - searchQuery={filtersSearchQuery} - /> -
- )} - - {/* state */} - {isFilterEnabled("state") && ( -
- handleFiltersUpdate("state", val)} - searchQuery={filtersSearchQuery} - states={states} - /> -
- )} - - {/* issue type */} - {isFilterEnabled("issue_type") && ( - handleFiltersUpdate("issue_type", val)} - searchQuery={filtersSearchQuery} - /> - )} - - {/* assignees */} - {isFilterEnabled("assignees") && ( -
- handleFiltersUpdate("assignees", val)} - memberIds={assigneeIds} - searchQuery={filtersSearchQuery} - /> -
- )} - - {/* cycle */} - {isFilterEnabled("cycle") && !cycleId && !cycleViewDisabled && ( -
- handleFiltersUpdate("cycle", val)} - searchQuery={filtersSearchQuery} - /> -
- )} - - {/* module */} - {isFilterEnabled("module") && !moduleId && !moduleViewDisabled && ( -
- handleFiltersUpdate("module", val)} - searchQuery={filtersSearchQuery} - /> -
- )} - - {/* assignees */} - {isFilterEnabled("mentions") && ( -
- handleFiltersUpdate("mentions", val)} - memberIds={memberIds} - searchQuery={filtersSearchQuery} - /> -
- )} - - {/* created_by */} - {isFilterEnabled("created_by") && ( -
- handleFiltersUpdate("created_by", val)} - memberIds={memberIds} - searchQuery={filtersSearchQuery} - /> -
- )} - - {/* labels */} - {isFilterEnabled("labels") && ( -
- handleFiltersUpdate("labels", val)} - labels={labels} - searchQuery={filtersSearchQuery} - /> -
- )} - - {/* project */} - {isFilterEnabled("project") && ( -
- handleFiltersUpdate("project", val)} - searchQuery={filtersSearchQuery} - /> -
- )} - - {/* team project */} - {isFilterEnabled("team_project") && ( -
- handleFiltersUpdate("team_project", val)} - searchQuery={filtersSearchQuery} - /> -
- )} - - {/* issue type */} - {isDisplayFilterEnabled("type") && displayFilters && handleDisplayFiltersUpdate && ( -
- - handleDisplayFiltersUpdate({ - type: val, - }) - } - isEpic={isEpic} - /> -
- )} - {/* start_date */} - {isFilterEnabled("start_date") && ( -
- handleFiltersUpdate("start_date", val)} - searchQuery={filtersSearchQuery} - /> -
- )} - - {/* target_date */} - {isFilterEnabled("target_date") && ( -
- handleFiltersUpdate("target_date", val)} - searchQuery={filtersSearchQuery} - /> -
- )} -
-
- ); -}); diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/filters/index.ts b/apps/web/core/components/issues/issue-layouts/filters/header/filters/index.ts index f4de8a278..65bca8ba8 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/header/filters/index.ts +++ b/apps/web/core/components/issues/issue-layouts/filters/header/filters/index.ts @@ -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"; diff --git a/apps/web/core/components/issues/issue-layouts/list/headers/group-by-card.tsx b/apps/web/core/components/issues/issue-layouts/list/headers/group-by-card.tsx index a6bf2efe4..60aabe585 100644 --- a/apps/web/core/components/issues/issue-layouts/list/headers/group-by-card.tsx +++ b/apps/web/core/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -116,7 +116,9 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => { >
{title}
{count || 0}
- +
+ +
{!disableIssueCreation && diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx index 1d83efc4e..6f450f6d1 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/issue-detail.tsx @@ -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"; diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index de20b469a..cd9cc96c8 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -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"; diff --git a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index f226750e4..7637c5faf 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -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"; diff --git a/apps/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx b/apps/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index bf0f73eb6..5abc58453 100644 --- a/apps/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -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 = 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 = 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 = 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 = observer((props: Props) => { ); } + if (!workspaceSlug || !globalViewId) return null; return ( - + + + {({ filter: globalWorkItemsFilter }) => ( +
+
+ {globalWorkItemsFilter && ( + + )} + +
+ {/* peek overview */} + +
+ )} +
+
); }); diff --git a/apps/web/core/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx b/apps/web/core/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx index 1a16dc42a..bbafd34bb 100644 --- a/apps/web/core/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx @@ -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 (
@@ -42,13 +47,25 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => { return ( - - -
- -
- -
+ + {({ filter: archivedWorkItemsFilter }) => ( + <> + {archivedWorkItemsFilter && } +
+ +
+ + + )} +
); }); diff --git a/apps/web/core/components/issues/issue-layouts/roots/cycle-layout-root.tsx b/apps/web/core/components/issues/issue-layouts/roots/cycle-layout-root.tsx index 0f71c3e95..1860073bf 100644 --- a/apps/web/core/components/issues/issue-layouts/roots/cycle-layout-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/roots/cycle-layout-root.tsx @@ -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 (
@@ -86,31 +89,48 @@ export const CycleLayoutRoot: React.FC = observer(() => { return ( - setTransferIssuesModal(false)} - cycleId={cycleId.toString()} - isOpen={transferIssuesModal} - /> -
- {cycleStatus === "completed" && ( - setTransferIssuesModal(true)} - canTransferIssues={canTransferIssues} - disabled={!isEmpty(cycleDetails?.progress_snapshot)} - /> + + {({ filter: cycleWorkItemsFilter }) => ( + <> + setTransferIssuesModal(false)} + cycleId={cycleId} + isOpen={transferIssuesModal} + /> +
+ {cycleStatus === "completed" && ( + setTransferIssuesModal(true)} + canTransferIssues={canTransferIssues} + disabled={!isEmpty(cycleDetails?.progress_snapshot)} + /> + )} + {cycleWorkItemsFilter && ( + + )} +
+ +
+ {/* peek overview */} + +
+ )} - - -
- -
- {/* peek overview */} - -
+
); }); diff --git a/apps/web/core/components/issues/issue-layouts/roots/module-layout-root.tsx b/apps/web/core/components/issues/issue-layouts/roots/module-layout-root.tsx index 158a5daa6..dc71e12e9 100644 --- a/apps/web/core/components/issues/issue-layouts/roots/module-layout-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/roots/module-layout-root.tsx @@ -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 (
); - const activeLayout = issueFilters?.displayFilters?.layout || undefined; - return ( -
- - - - - {/* peek overview */} - -
+ + {({ filter: moduleWorkItemsFilter }) => ( +
+ {moduleWorkItemsFilter && ( + + )} + + + + {/* peek overview */} + +
+ )} +
); }); diff --git a/apps/web/core/components/issues/issue-layouts/roots/project-layout-root.tsx b/apps/web/core/components/issues/issue-layouts/roots/project-layout-root.tsx index d2b789ce6..32c3931bd 100644 --- a/apps/web/core/components/issues/issue-layouts/roots/project-layout-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/roots/project-layout-root.tsx @@ -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 (
@@ -68,21 +72,40 @@ export const ProjectLayoutRoot: FC = observer(() => { return ( -
- -
- {/* mutation loader */} - {issues?.getIssueLoader() === "mutation" && ( -
- + + {({ filter: projectWorkItemsFilter }) => ( +
+ {projectWorkItemsFilter && ( + + )} +
+ {/* mutation loader */} + {issues?.getIssueLoader() === "mutation" && ( +
+ +
+ )} +
- )} - -
- - {/* peek overview */} - -
+ {/* peek overview */} + +
+ )} + ); }); diff --git a/apps/web/core/components/issues/issue-layouts/roots/project-view-layout-root.tsx b/apps/web/core/components/issues/issue-layouts/roots/project-view-layout-root.tsx index 050cfc056..769eb10a2 100644 --- a/apps/web/core/components/issues/issue-layouts/roots/project-view-layout-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/roots/project-view-layout-root.tsx @@ -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 (
@@ -67,15 +90,38 @@ export const ProjectViewLayoutRoot: React.FC = observer(() => { return ( -
- -
- -
- - {/* peek overview */} - -
+ + {({ filter: projectViewWorkItemsFilter }) => ( +
+ {projectViewWorkItemsFilter && ( + + )} +
+ +
+ {/* peek overview */} + +
+ )} +
); }); diff --git a/apps/web/core/components/issues/issue-layouts/save-filter-view.tsx b/apps/web/core/components/issues/issue-layouts/save-filter-view.tsx deleted file mode 100644 index 7c25038b3..000000000 --- a/apps/web/core/components/issues/issue-layouts/save-filter-view.tsx +++ /dev/null @@ -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 = (props) => { - const { workspaceSlug, projectId, filterParams, trackerElement } = props; - - const [viewModal, setViewModal] = useState(false); - - return ( -
- setViewModal(false)} - /> - - -
- ); -}; diff --git a/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx b/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx index b3dbec5f9..2b1608adc 100644 --- a/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx +++ b/apps/web/core/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx @@ -25,7 +25,7 @@ export const SpreadsheetLabelColumn: React.FC = observer((props: Props) =
onChange(issue, { label_ids: data }, { changed_property: "labels", change_details: data })} className="h-full w-full " diff --git a/apps/web/core/components/issues/issue-layouts/spreadsheet/roots/workspace-root.tsx b/apps/web/core/components/issues/issue-layouts/spreadsheet/roots/workspace-root.tsx index de635e6a9..7fc4b3af5 100644 --- a/apps/web/core/components/issues/issue-layouts/spreadsheet/roots/workspace-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/spreadsheet/roots/workspace-root.tsx @@ -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 = observer((props: Props) // Render spreadsheet return ( - - - - {/* peek overview */} - - - + + + ); }); diff --git a/apps/web/core/components/issues/issue-layouts/utils.tsx b/apps/web/core/components/issues/issue-layouts/utils.tsx index a7b0b9fc7..b4c1db970 100644 --- a/apps/web/core/components/issues/issue-layouts/utils.tsx +++ b/apps/web/core/components/issues/issue-layouts/utils.tsx @@ -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 diff --git a/apps/web/core/components/modules/analytics-sidebar/issue-progress.tsx b/apps/web/core/components/modules/analytics-sidebar/issue-progress.tsx index 53bd3a60d..7854298c2 100644 --- a/apps/web/core/components/modules/analytics-sidebar/issue-progress.tsx +++ b/apps/web/core/components/modules/analytics-sidebar/issue-progress.tsx @@ -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 = 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 = 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 = 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 = 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 (
@@ -234,17 +198,25 @@ export const ModuleAnalyticsProgress: FC = observer((p {chartDistributionData && (
)} diff --git a/apps/web/core/components/modules/analytics-sidebar/progress-stats.tsx b/apps/web/core/components/modules/analytics-sidebar/progress-stats.tsx index d64c54210..ceb11e3ea 100644 --- a/apps/web/core/components/modules/analytics-sidebar/progress-stats.tsx +++ b/apps/web/core/components/modules/analytics-sidebar/progress-stats.tsx @@ -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 ( -
- {distribution && distribution.length > 0 ? ( - distribution.map((assignee, index) => { - if (assignee?.id) - return ( - - - {assignee?.title ?? ""} -
- } - completed={assignee?.completed ?? 0} - total={assignee?.total ?? 0} - {...(isEditable && { - onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""), - selected: filters?.filters?.assignees?.includes(assignee.id ?? ""), - })} - /> - ); - else - return ( - -
- User -
- {t("no_assignee")} -
- } - completed={assignee?.completed ?? 0} - total={assignee?.total ?? 0} - /> - ); - }) - ) : ( -
-
- empty members -
-
{t("no_assignees_yet")}
-
- )} -
- ); -}); - -export const LabelStatComponent = observer((props: TLabelStatComponent) => { - const { distribution, isEditable, filters, handleFiltersUpdate } = props; - return ( -
- {distribution && distribution.length > 0 ? ( - distribution.map((label, index) => { - if (label.id) { - return ( - -
-

{label.title ?? "No labels"}

-
- } - completed={label.completed} - total={label.total} - {...(isEditable && { - onClick: () => handleFiltersUpdate("labels", label.id ?? ""), - selected: filters?.filters?.labels?.includes(label.id ?? `no-label-${index}`), - })} - /> - ); - } else { - return ( - - - {label.title ?? "No labels"} -
- } - completed={label.completed} - total={label.total} - /> - ); - } - }) - ) : ( -
-
- empty label -
-
No labels yet
-
- )} -
- ); -}); - -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 ( -
- {distribution.map((group, index) => ( - - - {group.state} -
- } - completed={group.completed} - total={totalIssuesCount} - {...(isEditable && { - onClick: () => group.state && handleFiltersUpdate("state", getStateGroupState(group.state) ?? []), - })} - /> - ))} -
- ); -}); - -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; - 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 = 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 = 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 (
@@ -327,7 +126,7 @@ export const ModuleProgressStats: FC = observer((props) => size === "xs" ? `text-xs` : `text-sm` )} > - {progressStats.map((stat) => ( + {PROGRESS_STATS.map((stat) => ( = observer((props) => key={stat.key} onClick={() => setModuleTab(stat.key)} > - {stat.title} + {t(stat.i18n_title)} ))} @@ -347,25 +146,26 @@ export const ModuleProgressStats: FC = observer((props) => - diff --git a/apps/web/core/components/profile/profile-issues-filter.tsx b/apps/web/core/components/profile/profile-issues-filter.tsx index b4f5ff6fd..8a3d242b7 100644 --- a/apps/web/core/components/profile/profile-issues-filter.tsx +++ b/apps/web/core/components/profile/profile-issues-filter.tsx @@ -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) => { if (!workspaceSlug || !userId) return; @@ -118,30 +73,10 @@ export const ProfileIssuesFilter = observer(() => { onChange={(layout) => handleLayoutChange(layout)} selectedLayout={activeLayout} /> - - - - - { 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 ( -
- -
- {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : null} -
-
- {/* peek overview */} - + + {({ filter: profileWorkItemsFilter }) => ( + <> +
+ {profileWorkItemsFilter && } +
+ {activeLayout === "list" ? ( + + ) : activeLayout === "kanban" ? ( + + ) : null} +
+
+ {/* peek overview */} + + + )} +
); }); diff --git a/apps/web/core/components/profile/sidebar.tsx b/apps/web/core/components/profile/sidebar.tsx index b01253546..5d63850c8 100644 --- a/apps/web/core/components/profile/sidebar.tsx +++ b/apps/web/core/components/profile/sidebar.tsx @@ -119,7 +119,7 @@ export const ProfileSidebar: FC = observer((props) => { className="h-full w-full rounded object-cover" /> ) : ( -
+
{userData?.first_name?.[0]}
)} diff --git a/apps/web/core/components/rich-filters/add-filters-button.tsx b/apps/web/core/components/rich-filters/add-filters-button.tsx index 512d16fee..52a87b37d 100644 --- a/apps/web/core/components/rich-filters/add-filters-button.tsx +++ b/apps/web/core/components/rich-filters/add-filters-button.tsx @@ -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

= { @@ -15,7 +15,7 @@ export type TAddFilterButtonProps

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

- {iconConfig.shouldShowIcon && - (iconConfig.iconComponent || )} + {iconConfig.shouldShowIcon && } {label}
} diff --git a/apps/web/core/components/rich-filters/filters-row.tsx b/apps/web/core/components/rich-filters/filters-row.tsx index 973cefe38..8749bde8a 100644 --- a/apps/web/core/components/rich-filters/filters-row.tsx +++ b/apps/web/core/components/rich-filters/filters-row.tsx @@ -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; 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) => ( + + ))} { - if (variant === "header") { - setShowAllConditions(true); - } - }} /> - {visibleConditions.map((condition) => ( - - ))} - {variant === "header" && hiddenConditionsCount > 0 && ( - - )} - {variant === "header" && - showAllConditions && - maxVisibleConditions && - filter.allConditionsForDisplay.length > maxVisibleConditions && ( - - )} ); @@ -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 = { diff --git a/apps/web/core/components/views/form.tsx b/apps/web/core/components/views/form.tsx index 278181079..afda65cd2 100644 --- a/apps/web/core/components/views/form.tsx +++ b/apps/web/core/components/views/form.tsx @@ -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; preLoadedData?: Partial | null; + projectId: string; + workspaceSlug: string; }; -const defaultValues: Partial = { +const DEFAULT_VALUES: Partial = { name: "", description: "", access: EViewAccess.PUBLIC, @@ -56,23 +50,24 @@ const defaultValues: Partial = { }; export const ProjectViewForm: React.FC = 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 = observer((props) => { } = useForm({ 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 = observer((props) => { }); }; - const clearAllFilters = () => { - if (!selectedFilters) return; - - setValue("filters", {}); - }; - - useEffect(() => { - reset({ - ...defaultValues, - ...preLoadedData, - ...data, - }); - }, [data, preLoadedData, reset]); - return (
@@ -263,43 +214,6 @@ export const ProjectViewForm: React.FC = observer((props) => { } value={displayFilters.layout} /> - - {/* filters dropdown */} - ( - - { - 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} - /> - - )} - /> - {/* display filters dropdown */} = observer((props) => { render={({ field: { onChange: onDisplayPropertiesChange, value: displayProperties } }) => ( ) => { onDisplayFiltersChange({ @@ -324,8 +240,8 @@ export const ProjectViewForm: React.FC = observer((props) => { ...updatedDisplayProperties, }); }} - cycleViewDisabled={!currentProjectDetails?.cycle_view} - moduleViewDisabled={!currentProjectDetails?.module_view} + cycleViewDisabled={!projectDetails?.cycle_view} + moduleViewDisabled={!projectDetails?.module_view} /> )} @@ -334,17 +250,31 @@ export const ProjectViewForm: React.FC = observer((props) => { )} />
- {selectedFilters && Object.keys(selectedFilters).length > 0 && ( -
- -
- )} +
+ {/* filters dropdown */} + ( + onFiltersChange(updateFilters)} + projectId={projectId} + workspaceSlug={workspaceSlug} + > + {({ filter: projectViewWorkItemsFilter }) => + projectViewWorkItemsFilter && ( + + ) + } + + )} + /> +
diff --git a/apps/web/core/components/views/helper.tsx b/apps/web/core/components/views/helper.tsx index 1d4177509..d43a004b5 100644 --- a/apps/web/core/components/views/helper.tsx +++ b/apps/web/core/components/views/helper.tsx @@ -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; diff --git a/apps/web/core/components/views/modal.tsx b/apps/web/core/components/views/modal.tsx index 81df6abe0..7696fb8ad 100644 --- a/apps/web/core/components/views/modal.tsx +++ b/apps/web/core/components/views/modal.tsx @@ -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 = 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 = 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 = observer((props) => { handleClose={handleClose} handleFormSubmit={handleFormSubmit} preLoadedData={preLoadedData} + projectId={projectId} + workspaceSlug={workspaceSlug} /> ); diff --git a/apps/web/core/components/views/update-view-component.tsx b/apps/web/core/components/views/update-view-component.tsx deleted file mode 100644 index 54b6ae47d..000000000 --- a/apps/web/core/components/views/update-view-component.tsx +++ /dev/null @@ -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) => 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 ? ( - - ) : ( - - ); - - return ( -
- {!isLocked && !areFiltersEqual && isAuthorizedUser && ( - <> - - {isOwner && <>{updateButton}} - - )} -
- ); -}; diff --git a/apps/web/core/components/views/view-list-item-action.tsx b/apps/web/core/components/views/view-list-item-action.tsx index d423998bb..181d34c0d 100644 --- a/apps/web/core/components/views/view-list-item-action.tsx +++ b/apps/web/core/components/views/view-list-item-action.tsx @@ -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 = 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 = observer((props) => { /> )} setDeleteViewModal(false)} /> -

- {totalFilters} {totalFilters === 1 ? "filter" : "filters"} -

-
{access === EViewAccess.PUBLIC ? : } diff --git a/apps/web/core/components/work-item-filters/filters-hoc/base.tsx b/apps/web/core/components/work-item-filters/filters-hoc/base.tsx new file mode 100644 index 000000000..83d01fa80 --- /dev/null +++ b/apps/web/core/components/work-item-filters/filters-hoc/base.tsx @@ -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; + updateViewOptions?: TUpdateViewOptions; +} & 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 ( + + {children} + + ); +}); + +type TWorkItemFilterProps = TSharedWorkItemFiltersProps & + TAdditionalWorkItemFiltersProps & { + initialWorkItemFilters: IIssueFilters; + children: + | React.ReactNode + | ((props: { filter: IFilterInstance }) => 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}; +}); diff --git a/apps/web/core/components/work-item-filters/filters-hoc/project-level.tsx b/apps/web/core/components/work-item-filters/filters-hoc/project-level.tsx new file mode 100644 index 000000000..19794a53b --- /dev/null +++ b/apps/web/core/components/work-item-filters/filters-hoc/project-level.tsx @@ -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 | 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 = 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 = 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 ( + <> + { + setCreateViewPayload(null); + setIsCreateViewModalOpen(false); + }} + /> + { + 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} + + + ); +}); diff --git a/apps/web/core/components/work-item-filters/filters-hoc/shared.ts b/apps/web/core/components/work-item-filters/filters-hoc/shared.ts new file mode 100644 index 000000000..775dd45fd --- /dev/null +++ b/apps/web/core/components/work-item-filters/filters-hoc/shared.ts @@ -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 | undefined; + }) => React.ReactNode); + initialWorkItemFilters: IIssueFilters | undefined; +}; + +export type TEnableSaveViewProps = { + enableSaveView?: boolean; + saveViewOptions?: Omit, "onViewSave">; +}; + +export type TEnableUpdateViewProps = { + enableUpdateView?: boolean; + updateViewOptions?: Omit, "onViewUpdate">; +}; diff --git a/apps/web/core/components/work-item-filters/filters-hoc/workspace-level.tsx b/apps/web/core/components/work-item-filters/filters-hoc/workspace-level.tsx new file mode 100644 index 000000000..7f8cc3e13 --- /dev/null +++ b/apps/web/core/components/work-item-filters/filters-hoc/workspace-level.tsx @@ -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 | 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 = useCallback( + () => ({ + name: viewDetails ? `${viewDetails?.name} 2` : "Untitled", + description: viewDetails ? viewDetails.description : "", + access: viewDetails ? viewDetails.access : EViewAccess.PUBLIC, + }), + [viewDetails] + ); + + const getViewFilterPayload: (filterExpression: TWorkItemFilterExpression) => Partial = 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 ( + <> + { + setCreateViewPayload(undefined); + setIsCreateViewModalOpen(false); + }} + /> + { + 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} + + + ); +}); diff --git a/apps/web/core/components/work-item-filters/work-item-filters-row.tsx b/apps/web/core/components/work-item-filters/work-item-filters-row.tsx new file mode 100644 index 000000000..b2fa56e38 --- /dev/null +++ b/apps/web/core/components/work-item-filters/work-item-filters-row.tsx @@ -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; + +export const WorkItemFiltersRow = observer((props: TWorkItemFiltersRowProps) => ); diff --git a/apps/web/core/components/workspace/sidebar/help-menu.tsx b/apps/web/core/components/workspace/sidebar/help-menu.tsx index ea6511098..82f179caf 100644 --- a/apps/web/core/components/workspace/sidebar/help-menu.tsx +++ b/apps/web/core/components/workspace/sidebar/help-menu.tsx @@ -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 = 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 = observer(() => {
- -
{ - e.preventDefault(); - e.stopPropagation(); - }} - className="flex w-full items-center justify-between text-xs hover:bg-custom-background-80" - > - {t("hyper_mode")} - toggleLocalDB(workspaceSlug?.toString(), projectId?.toString())} - /> -
-
diff --git a/apps/web/core/components/workspace/views/modal.tsx b/apps/web/core/components/workspace/views/modal.tsx index de54f114a..7adb4d330 100644 --- a/apps/web/core/components/workspace/views/modal.tsx +++ b/apps/web/core/components/workspace/views/modal.tsx @@ -5,12 +5,13 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports import { GLOBAL_VIEW_TRACKER_EVENTS } from "@plane/constants"; -import { IWorkspaceView } from "@plane/types"; +import { EIssuesStoreType, IWorkspaceView } from "@plane/types"; import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui"; // helpers import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; // hooks import { useGlobalView } from "@/hooks/store/use-global-view"; +import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters"; import { useAppRouter } from "@/hooks/use-app-router"; // local imports import { WorkspaceViewForm } from "./form"; @@ -26,9 +27,11 @@ export const CreateUpdateWorkspaceViewModal: React.FC = observer((props) const { isOpen, onClose, data, preLoadedData } = props; // router const router = useAppRouter(); - const { workspaceSlug } = useParams(); + const { workspaceSlug: routerWorkspaceSlug } = useParams(); + const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined; // store hooks const { createGlobalView, updateGlobalView } = useGlobalView(); + const { resetExpression } = useWorkItemFilters(); const handleClose = () => { onClose(); @@ -39,12 +42,12 @@ export const CreateUpdateWorkspaceViewModal: React.FC = observer((props) const payloadData: Partial = { ...payload, - filters: { - ...payload?.filters, + rich_filters: { + ...payload?.rich_filters, }, }; - await createGlobalView(workspaceSlug.toString(), payloadData) + await createGlobalView(workspaceSlug, payloadData) .then((res) => { captureSuccess({ eventName: GLOBAL_VIEW_TRACKER_EVENTS.create, @@ -79,13 +82,14 @@ export const CreateUpdateWorkspaceViewModal: React.FC = observer((props) const payloadData: Partial = { ...payload, query: { - ...payload?.filters, + ...payload?.rich_filters, }, }; - await updateGlobalView(workspaceSlug.toString(), data.id, payloadData) + await updateGlobalView(workspaceSlug, data.id, payloadData) .then((res) => { if (res) { + resetExpression(EIssuesStoreType.GLOBAL, data.id, res.rich_filters); captureSuccess({ eventName: GLOBAL_VIEW_TRACKER_EVENTS.update, payload: { @@ -122,6 +126,7 @@ export const CreateUpdateWorkspaceViewModal: React.FC = observer((props) else await handleUpdateView(formData); }; + if (!workspaceSlug) return null; return ( = observer((props) handleClose={handleClose} data={data} preLoadedData={preLoadedData} + workspaceSlug={workspaceSlug} /> ); diff --git a/apps/web/core/components/workspace/views/quick-action.tsx b/apps/web/core/components/workspace/views/quick-action.tsx index adb6b5e4b..e7677cecb 100644 --- a/apps/web/core/components/workspace/views/quick-action.tsx +++ b/apps/web/core/components/workspace/views/quick-action.tsx @@ -11,8 +11,8 @@ import { copyUrlToClipboard, cn } from "@plane/utils"; import { captureClick } from "@/helpers/event-tracker.helper"; // hooks import { useUser, useUserPermissions } from "@/hooks/store/user"; -// local imports import { useViewMenuItems } from "@/plane-web/components/views/helper"; +// local imports import { DeleteGlobalViewModal } from "./delete-view-modal"; import { CreateUpdateWorkspaceViewModal } from "./modal"; diff --git a/apps/web/core/components/workspace/views/view-list-item.tsx b/apps/web/core/components/workspace/views/view-list-item.tsx index 1bcc4d851..d1d794a84 100644 --- a/apps/web/core/components/workspace/views/view-list-item.tsx +++ b/apps/web/core/components/workspace/views/view-list-item.tsx @@ -8,7 +8,7 @@ import { Pencil, Trash2 } from "lucide-react"; // plane imports import { GLOBAL_VIEW_TRACKER_ELEMENTS } from "@plane/constants"; import { CustomMenu } from "@plane/ui"; -import { calculateTotalFilters, truncateText } from "@plane/utils"; +import { truncateText } from "@plane/utils"; // helpers import { captureClick } from "@/helpers/event-tracker.helper"; // hooks @@ -33,8 +33,6 @@ export const GlobalViewListItem: React.FC = observer((props) => { if (!view) return null; - const totalFilters = calculateTotalFilters(view.filters ?? {}); - return ( <> setUpdateViewModal(false)} /> @@ -51,9 +49,6 @@ export const GlobalViewListItem: React.FC = observer((props) => {
-

- {totalFilters} {totalFilters === 1 ? "filter" : "filters"} -

{ diff --git a/apps/web/core/hooks/store/work-item-filters/use-work-item-filter-instance.ts b/apps/web/core/hooks/store/work-item-filters/use-work-item-filter-instance.ts new file mode 100644 index 000000000..6d178eaf6 --- /dev/null +++ b/apps/web/core/hooks/store/work-item-filters/use-work-item-filter-instance.ts @@ -0,0 +1,13 @@ +// plane imports +import { IFilterInstance } from "@plane/shared-state"; +import { EIssuesStoreType, TWorkItemFilterExpression, TWorkItemFilterProperty } from "@plane/types"; +// local imports +import { useWorkItemFilters } from "./use-work-item-filters"; + +export const useWorkItemFilterInstance = ( + entityType: EIssuesStoreType, + entityId: string +): IFilterInstance | undefined => { + const { getFilter } = useWorkItemFilters(); + return getFilter(entityType, entityId); +}; diff --git a/apps/web/core/hooks/store/work-item-filters/use-work-item-filters.ts b/apps/web/core/hooks/store/work-item-filters/use-work-item-filters.ts new file mode 100644 index 000000000..7de449863 --- /dev/null +++ b/apps/web/core/hooks/store/work-item-filters/use-work-item-filters.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// plane imports +import { IWorkItemFilterStore } from "@plane/shared-state"; +// context +import { StoreContext } from "@/lib/store-context"; + +export const useWorkItemFilters = (): IWorkItemFilterStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useWorkItemFilters must be used within StoreProvider"); + return context.workItemFilters; +}; diff --git a/apps/web/core/hooks/use-issues-actions.tsx b/apps/web/core/hooks/use-issues-actions.tsx index cdfb5864f..eb8c45121 100644 --- a/apps/web/core/hooks/use-issues-actions.tsx +++ b/apps/web/core/hooks/use-issues-actions.tsx @@ -1,23 +1,23 @@ +/* eslint-disable react-hooks/exhaustive-deps */ import { useCallback, useMemo } from "react"; // types import { useParams } from "next/navigation"; -import { EIssueFilterType, EDraftIssuePaginationType } from "@plane/constants"; +import { EDraftIssuePaginationType, TSupportedFilterTypeForUpdate } from "@plane/constants"; import { EIssuesStoreType, IIssueDisplayFilterOptions, IIssueDisplayProperties, - IIssueFilterOptions, IssuePaginationOptions, TIssue, - TIssueKanbanFilters, TIssuesResponse, TLoader, TProfileViews, + TSupportedFilterForUpdate, } from "@plane/types"; import { useTeamIssueActions, - useTeamViewIssueActions, useTeamProjectWorkItemsActions, + useTeamViewIssueActions, } from "@/plane-web/helpers/issue-action-helper"; import { useIssues } from "./store/use-issues"; @@ -37,8 +37,8 @@ export interface IssueActions { restoreIssue?: (projectId: string | undefined | null, issueId: string) => Promise; updateFilters: ( projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters + filterType: TSupportedFilterTypeForUpdate, + filters: TSupportedFilterForUpdate ) => Promise; } @@ -146,11 +146,7 @@ const useProjectIssueActions = () => { ); const updateFilters = useCallback( - async ( - projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters - ) => { + async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => { if (!workspaceSlug) return; return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters); }, @@ -232,11 +228,7 @@ const useProjectEpicsActions = () => { ); const updateFilters = useCallback( - async ( - projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters - ) => { + async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => { if (!workspaceSlug) return; return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters); }, @@ -332,11 +324,7 @@ const useCycleIssueActions = () => { ); const updateFilters = useCallback( - async ( - projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters - ) => { + async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => { if (!cycleId || !workspaceSlug) return; return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters, cycleId); }, @@ -443,11 +431,7 @@ const useModuleIssueActions = () => { ); const updateFilters = useCallback( - async ( - projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters - ) => { + async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => { if (!moduleId || !workspaceSlug) return; return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters, moduleId); }, @@ -529,11 +513,7 @@ const useProfileIssueActions = () => { ); const updateFilters = useCallback( - async ( - projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters - ) => { + async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => { if (!userId || !workspaceSlug) return; return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters, userId); }, @@ -615,11 +595,7 @@ const useProjectViewIssueActions = () => { ); const updateFilters = useCallback( - async ( - projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters - ) => { + async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => { if (!viewId || !workspaceSlug) return; return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters, viewId); }, @@ -680,11 +656,7 @@ const useArchivedIssueActions = () => { ); const updateFilters = useCallback( - async ( - projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters - ) => { + async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => { if (!workspaceSlug) return; return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters); }, @@ -749,11 +721,7 @@ const useGlobalIssueActions = () => { ); const updateFilters = useCallback( - async ( - projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters - ) => { + async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => { if (!globalViewId || !workspaceSlug) return; return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters, globalViewId); }, @@ -781,7 +749,7 @@ const useWorkspaceDraftIssueActions = () => { // store hooks const { issues, issuesFilter } = useIssues(EIssuesStoreType.WORKSPACE_DRAFT); const fetchIssues = useCallback( - async (loadType: TLoader, options: IssuePaginationOptions) => { + async (loadType: TLoader, _options: IssuePaginationOptions) => { if (!workspaceSlug) return; return issues.fetchIssues(workspaceSlug.toString(), loadType, EDraftIssuePaginationType.INIT); }, @@ -824,12 +792,8 @@ const useWorkspaceDraftIssueActions = () => { // ); const updateFilters = useCallback( - async ( - projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters - ) => { - filters = filters as IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties; + async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => { + filters = filters as IIssueDisplayFilterOptions | IIssueDisplayProperties; if (!globalViewId || !workspaceSlug) return; return await issuesFilter.updateFilters(workspaceSlug, filterType, filters); }, diff --git a/apps/web/core/store/global-view.store.ts b/apps/web/core/store/global-view.store.ts index 7ed35b307..3915c5251 100644 --- a/apps/web/core/store/global-view.store.ts +++ b/apps/web/core/store/global-view.store.ts @@ -1,12 +1,10 @@ import cloneDeep from "lodash/cloneDeep"; -import isEmpty from "lodash/isEmpty"; import isEqual from "lodash/isEqual"; import set from "lodash/set"; -import { observable, action, makeObservable, runInAction, computed } from "mobx"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; -import { EIssueFilterType } from "@plane/constants"; -import { IIssueFilterOptions, IWorkspaceView } from "@plane/types"; -// constants +// plane imports +import { IWorkspaceView } from "@plane/types"; // services import { WorkspaceService } from "@/plane-web/services"; // store @@ -28,7 +26,8 @@ export interface IGlobalViewStore { updateGlobalView: ( workspaceSlug: string, viewId: string, - data: Partial + data: Partial, + shouldSyncFilters?: boolean ) => Promise; deleteGlobalView: (workspaceSlug: string, viewId: string) => Promise; } @@ -156,7 +155,8 @@ export class GlobalViewStore implements IGlobalViewStore { async updateGlobalView( workspaceSlug: string, viewId: string, - data: Partial + data: Partial, + shouldSyncFilters: boolean = true ): Promise { const currentViewData = this.getViewDetailsById(viewId) ? cloneDeep(this.getViewDetailsById(viewId)) : undefined; try { @@ -168,31 +168,12 @@ export class GlobalViewStore implements IGlobalViewStore { const currentView = await this.workspaceService.updateView(workspaceSlug, viewId, data); // applying the filters in the global view - if (!isEqual(currentViewData?.filters || {}, currentView?.filters || {})) { - if (!currentView?.filters || isEmpty(currentView?.filters)) { - const currentGlobalViewFilters: IIssueFilterOptions = this.rootStore.issue.workspaceIssuesFilter.filters[ - viewId - ].filters as IIssueFilterOptions; - const newFilters: IIssueFilterOptions = {}; - Object.keys(currentGlobalViewFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = []; - }); - await this.rootStore.issue.workspaceIssuesFilter.updateFilters( - workspaceSlug, - undefined, - EIssueFilterType.FILTERS, - newFilters, - viewId - ); - } else { - await this.rootStore.issue.workspaceIssuesFilter.updateFilters( - workspaceSlug, - undefined, - EIssueFilterType.FILTERS, - currentView?.filters, - viewId - ); - } + if (shouldSyncFilters && !isEqual(currentViewData?.rich_filters || {}, currentView?.rich_filters || {})) { + await this.rootStore.issue.workspaceIssuesFilter.updateFilterExpression( + workspaceSlug, + viewId, + currentView?.rich_filters || {} + ); this.rootStore.issue.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation"); } return currentView as IWorkspaceView; diff --git a/apps/web/core/store/issue/archived/filter.store.ts b/apps/web/core/store/issue/archived/filter.store.ts index f48b8b3a1..1d2ac4b2a 100644 --- a/apps/web/core/store/issue/archived/filter.store.ts +++ b/apps/web/core/store/issue/archived/filter.store.ts @@ -1,20 +1,19 @@ -import isArray from "lodash/isArray"; import isEmpty from "lodash/isEmpty"; -import pickBy from "lodash/pickBy"; import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // base class import { computedFn } from "mobx-utils"; -import { EIssueFilterType } from "@plane/constants"; +import { EIssueFilterType, TSupportedFilterTypeForUpdate } from "@plane/constants"; import { EIssuesStoreType, - IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueKanbanFilters, IIssueFilters, TIssueParams, IssuePaginationOptions, + TWorkItemFilterExpression, + TSupportedFilterForUpdate, } from "@plane/types"; import { handleIssueQueryParamsByLayout } from "@plane/utils"; import { IssueFiltersService } from "@/services/issue_filter.service"; @@ -37,11 +36,16 @@ export interface IArchivedIssuesFilter extends IBaseIssueFilterStore { getIssueFilters(projectId: string): IIssueFilters | undefined; // action fetchFilters: (workspaceSlug: string, projectId: string) => Promise; + updateFilterExpression: ( + workspaceSlug: string, + projectId: string, + filters: TWorkItemFilterExpression + ) => Promise; updateFilters: ( workspaceSlug: string, projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters + filterType: TSupportedFilterTypeForUpdate, + filters: TSupportedFilterForUpdate ) => Promise; } @@ -102,8 +106,8 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc if (!filteredParams) return undefined; const filteredRouteParams: Partial> = this.computedFilteredParams( - userFilters?.filters as IIssueFilterOptions, - userFilters?.displayFilters as IIssueDisplayFilterOptions, + userFilters?.richFilters, + userFilters?.displayFilters, filteredParams ); @@ -128,7 +132,7 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc fetchFilters = async (workspaceSlug: string, projectId: string) => { const _filters = this.handleIssuesLocalFilters.get(EIssuesStoreType.ARCHIVED, workspaceSlug, projectId, undefined); - const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters); + const richFilters: TWorkItemFilterExpression = _filters?.richFilters; const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters({ ..._filters?.display_filters, sub_issue: true, @@ -142,51 +146,57 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc kanbanFilters.sub_group_by = _filters?.kanban_filters?.sub_group_by || []; runInAction(() => { - set(this.filters, [projectId, "filters"], filters); + set(this.filters, [projectId, "richFilters"], richFilters); set(this.filters, [projectId, "displayFilters"], displayFilters); set(this.filters, [projectId, "displayProperties"], displayProperties); set(this.filters, [projectId, "kanbanFilters"], kanbanFilters); }); }; - updateFilters = async ( - workspaceSlug: string, - projectId: string, - type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters + /** + * NOTE: This method is designed as a fallback function for the work item filter store. + * Only use this method directly when initializing filter instances. + * For regular filter updates, use this method as a fallback function for the work item filter store methods instead. + */ + updateFilterExpression: IArchivedIssuesFilter["updateFilterExpression"] = async ( + workspaceSlug, + projectId, + filters ) => { try { - if (isEmpty(this.filters) || isEmpty(this.filters[projectId]) || isEmpty(filters)) return; + runInAction(() => { + set(this.filters, [projectId, "richFilters"], filters); + }); + + this.rootIssueStore.archivedIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation"); + this.handleIssuesLocalFilters.set( + EIssuesStoreType.ARCHIVED, + EIssueFilterType.FILTERS, + workspaceSlug, + projectId, + undefined, + { + rich_filters: filters, + } + ); + } catch (error) { + console.log("error while updating rich filters", error); + throw error; + } + }; + + updateFilters: IArchivedIssuesFilter["updateFilters"] = async (workspaceSlug, projectId, type, filters) => { + try { + if (isEmpty(this.filters) || isEmpty(this.filters[projectId])) return; const _filters = { - filters: this.filters[projectId].filters as IIssueFilterOptions, + richFilters: this.filters[projectId].richFilters, displayFilters: this.filters[projectId].displayFilters as IIssueDisplayFilterOptions, displayProperties: this.filters[projectId].displayProperties as IIssueDisplayProperties, kanbanFilters: this.filters[projectId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { - case EIssueFilterType.FILTERS: { - const updatedFilters = filters as IIssueFilterOptions; - _filters.filters = { ..._filters.filters, ...updatedFilters }; - - runInAction(() => { - Object.keys(updatedFilters).forEach((_key) => { - set(this.filters, [projectId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); - }); - }); - const appliedFilters = _filters.filters || {}; - const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); - this.rootIssueStore.archivedIssues.fetchIssuesWithExistingPagination( - workspaceSlug, - projectId, - isEmpty(filteredFilters) ? "init-loader" : "mutation" - ); - this.handleIssuesLocalFilters.set(EIssuesStoreType.ARCHIVED, type, workspaceSlug, projectId, undefined, { - filters: _filters.filters, - }); - break; - } case EIssueFilterType.DISPLAY_FILTERS: { const updatedDisplayFilters = filters as IIssueDisplayFilterOptions; _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; diff --git a/apps/web/core/store/issue/cycle/filter.store.ts b/apps/web/core/store/issue/cycle/filter.store.ts index 239ca2ece..9e65dcf4d 100644 --- a/apps/web/core/store/issue/cycle/filter.store.ts +++ b/apps/web/core/store/issue/cycle/filter.store.ts @@ -3,16 +3,17 @@ import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // base class import { computedFn } from "mobx-utils"; -import { EIssueFilterType } from "@plane/constants"; +import { EIssueFilterType, TSupportedFilterTypeForUpdate } from "@plane/constants"; import { EIssuesStoreType, - IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueKanbanFilters, IIssueFilters, TIssueParams, IssuePaginationOptions, + TWorkItemFilterExpression, + TSupportedFilterForUpdate, } from "@plane/types"; import { handleIssueQueryParamsByLayout } from "@plane/utils"; import { IssueFiltersService } from "@/services/issue_filter.service"; @@ -35,11 +36,17 @@ export interface ICycleIssuesFilter extends IBaseIssueFilterStore { getIssueFilters(cycleId: string): IIssueFilters | undefined; // action fetchFilters: (workspaceSlug: string, projectId: string, cycleId: string) => Promise; + updateFilterExpression: ( + workspaceSlug: string, + projectId: string, + cycleId: string, + filters: TWorkItemFilterExpression + ) => Promise; updateFilters: ( workspaceSlug: string, projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, + filterType: TSupportedFilterTypeForUpdate, + filters: TSupportedFilterForUpdate, cycleId: string ) => Promise; } @@ -103,8 +110,8 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI if (filteredParams.includes("cycle")) filteredParams.splice(filteredParams.indexOf("cycle"), 1); const filteredRouteParams: Partial> = this.computedFilteredParams( - userFilters?.filters as IIssueFilterOptions, - userFilters?.displayFilters as IIssueDisplayFilterOptions, + userFilters?.richFilters, + userFilters?.displayFilters, filteredParams ); @@ -134,7 +141,7 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI fetchFilters = async (workspaceSlug: string, projectId: string, cycleId: string) => { const _filters = await this.issueFilterService.fetchCycleIssueFilters(workspaceSlug, projectId, cycleId); - const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters); + const richFilters: TWorkItemFilterExpression = _filters?.rich_filters; const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); @@ -156,52 +163,51 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI } runInAction(() => { - set(this.filters, [cycleId, "filters"], filters); + set(this.filters, [cycleId, "richFilters"], richFilters); set(this.filters, [cycleId, "displayFilters"], displayFilters); set(this.filters, [cycleId, "displayProperties"], displayProperties); set(this.filters, [cycleId, "kanbanFilters"], kanbanFilters); }); }; - updateFilters = async ( - workspaceSlug: string, - projectId: string, - type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, - cycleId: string + /** + * NOTE: This method is designed as a fallback function for the work item filter store. + * Only use this method directly when initializing filter instances. + * For regular filter updates, use this method as a fallback function for the work item filter store methods instead. + */ + updateFilterExpression: ICycleIssuesFilter["updateFilterExpression"] = async ( + workspaceSlug, + projectId, + cycleId, + filters ) => { try { - if (isEmpty(this.filters) || isEmpty(this.filters[cycleId]) || isEmpty(filters)) return; + runInAction(() => { + set(this.filters, [cycleId, "richFilters"], filters); + }); + + this.rootIssueStore.cycleIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation", cycleId); + await this.issueFilterService.patchCycleIssueFilters(workspaceSlug, projectId, cycleId, { + rich_filters: filters, + }); + } catch (error) { + console.log("error while updating rich filters", error); + throw error; + } + }; + + updateFilters: ICycleIssuesFilter["updateFilters"] = async (workspaceSlug, projectId, type, filters, cycleId) => { + try { + if (isEmpty(this.filters) || isEmpty(this.filters[cycleId])) return; const _filters = { - filters: this.filters[cycleId].filters as IIssueFilterOptions, + richFilters: this.filters[cycleId].richFilters as TWorkItemFilterExpression, displayFilters: this.filters[cycleId].displayFilters as IIssueDisplayFilterOptions, displayProperties: this.filters[cycleId].displayProperties as IIssueDisplayProperties, kanbanFilters: this.filters[cycleId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { - case EIssueFilterType.FILTERS: { - const updatedFilters = filters as IIssueFilterOptions; - _filters.filters = { ..._filters.filters, ...updatedFilters }; - - runInAction(() => { - Object.keys(updatedFilters).forEach((_key) => { - set(this.filters, [cycleId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); - }); - }); - - this.rootIssueStore.cycleIssues.fetchIssuesWithExistingPagination( - workspaceSlug, - projectId, - "mutation", - cycleId - ); - await this.issueFilterService.patchCycleIssueFilters(workspaceSlug, projectId, cycleId, { - filters: _filters.filters, - }); - break; - } case EIssueFilterType.DISPLAY_FILTERS: { const updatedDisplayFilters = filters as IIssueDisplayFilterOptions; _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; diff --git a/apps/web/core/store/issue/cycle/issue.store.ts b/apps/web/core/store/issue/cycle/issue.store.ts index 34917d7d1..48b5a17d0 100644 --- a/apps/web/core/store/issue/cycle/issue.store.ts +++ b/apps/web/core/store/issue/cycle/issue.store.ts @@ -141,15 +141,20 @@ export class CycleIssues extends BaseIssuesStore implements ICycleIssues { fetchParentStats = (workspaceSlug: string, projectId?: string | undefined, id?: string | undefined) => { const cycleId = id ?? this.cycleId; - projectId && cycleId && this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); + if (projectId && cycleId) { + this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); + } // fetch cycle progress const isSidebarCollapsed = storage.get("cycle_sidebar_collapsed"); - projectId && + if ( + projectId && cycleId && this.rootIssueStore.rootStore.cycle.getCycleById(cycleId)?.version === 2 && isSidebarCollapsed && - JSON.parse(isSidebarCollapsed) === false && + JSON.parse(isSidebarCollapsed) === false + ) { this.rootIssueStore.rootStore.cycle.fetchActiveCycleProgressPro(workspaceSlug, projectId, cycleId); + } }; updateParentStats = (prevIssueState?: TIssue, nextIssueState?: TIssue, id?: string | undefined) => { @@ -162,8 +167,9 @@ export class CycleIssues extends BaseIssuesStore implements ICycleIssues { ); const cycleId = id ?? this.cycleId; - - cycleId && this.rootIssueStore.rootStore.cycle.updateCycleDistribution(distributionUpdates, cycleId); + if (cycleId) { + this.rootIssueStore.rootStore.cycle.updateCycleDistribution(distributionUpdates, cycleId); + } } catch (e) { console.warn("could not update cycle statistics"); } diff --git a/apps/web/core/store/issue/helpers/base-issues-utils.ts b/apps/web/core/store/issue/helpers/base-issues-utils.ts index 1ca57d6cd..42892365b 100644 --- a/apps/web/core/store/issue/helpers/base-issues-utils.ts +++ b/apps/web/core/store/issue/helpers/base-issues-utils.ts @@ -10,7 +10,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, - IIssueFilters, + ISubWorkItemFilters, TIssue, TIssueGroupByOptions, TIssueOrderByOptions, @@ -357,8 +357,8 @@ export const getGroupedWorkItemIds = ( * @param filters - The filters to update. * @param workItemId - The ID of the work item to update. */ -export const updateFilters = ( - filtersMap: Record>, +export const updateSubWorkItemFilters = ( + filtersMap: Record>, filterType: EIssueFilterType, filters: IIssueDisplayFilterOptions | IIssueDisplayProperties | IIssueFilterOptions, workItemId: string diff --git a/apps/web/core/store/issue/helpers/base-issues.store.ts b/apps/web/core/store/issue/helpers/base-issues.store.ts index c12bd9e9e..610797f85 100644 --- a/apps/web/core/store/issue/helpers/base-issues.store.ts +++ b/apps/web/core/store/issue/helpers/base-issues.store.ts @@ -4,7 +4,6 @@ import get from "lodash/get"; import indexOf from "lodash/indexOf"; import isEmpty from "lodash/isEmpty"; import isEqual from "lodash/isEqual"; -import isNil from "lodash/isNil"; import orderBy from "lodash/orderBy"; import pull from "lodash/pull"; import set from "lodash/set"; @@ -95,6 +94,8 @@ export interface IBaseIssuesStore { addCycleToIssue: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; removeCycleFromIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + addIssueToList: (issueId: string) => void; + removeIssueFromList: (issueId: string) => void; addIssuesToModule: ( workspaceSlug: string, projectId: string, diff --git a/apps/web/core/store/issue/helpers/issue-filter-helper.store.ts b/apps/web/core/store/issue/helpers/issue-filter-helper.store.ts index 647ba67c5..393e9943d 100644 --- a/apps/web/core/store/issue/helpers/issue-filter-helper.store.ts +++ b/apps/web/core/store/issue/helpers/issue-filter-helper.store.ts @@ -18,6 +18,7 @@ import { TIssueParams, TStaticViewTypes, EIssueLayoutTypes, + TWorkItemFilterExpression, } from "@plane/types"; // helpers import { getComputedDisplayFilters, getComputedDisplayProperties } from "@plane/utils"; @@ -43,11 +44,15 @@ export interface IBaseIssueFilterStore { export interface IIssueFilterHelperStore { computedIssueFilters(filters: IIssueFilters): IIssueFilters; computedFilteredParams( - filters: IIssueFilterOptions, - displayFilters: IIssueDisplayFilterOptions, - filteredParams: TIssueParams[] + richFilters: TWorkItemFilterExpression, + displayFilters: IIssueDisplayFilterOptions | undefined, + acceptableParamsByLayout: TIssueParams[] ): Partial>; computedFilters(filters: IIssueFilterOptions): IIssueFilterOptions; + getFilterConditionBasedOnViews: ( + currentUserId: string | undefined, + type: TStaticViewTypes + ) => Partial> | undefined; computedDisplayFilters( displayFilters: IIssueDisplayFilterOptions, defaultValues?: IIssueDisplayFilterOptions @@ -64,7 +69,7 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { * @returns {IIssueFilters} */ computedIssueFilters = (filters: IIssueFilters): IIssueFilters => ({ - filters: isEmpty(filters?.filters) ? undefined : filters?.filters, + richFilters: isEmpty(filters?.richFilters) ? {} : filters?.richFilters, displayFilters: isEmpty(filters?.displayFilters) ? undefined : filters?.displayFilters, displayProperties: isEmpty(filters?.displayProperties) ? undefined : filters?.displayProperties, kanbanFilters: isEmpty(filters?.kanbanFilters) ? undefined : filters?.kanbanFilters, @@ -72,47 +77,29 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { /** * @description This method is used to convert the filters array params to string params - * @param {IIssueFilterOptions} filters + * @param {TWorkItemFilterExpression} richFilters * @param {IIssueDisplayFilterOptions} displayFilters * @param {string[]} acceptableParamsByLayout * @returns {Partial>} */ computedFilteredParams = ( - filters: IIssueFilterOptions, - displayFilters: IIssueDisplayFilterOptions, + richFilters: TWorkItemFilterExpression, + displayFilters: IIssueDisplayFilterOptions | undefined, acceptableParamsByLayout: TIssueParams[] - ) => { - const computedFilters: Partial> = { - // issue filters - priority: filters?.priority || undefined, - state_group: filters?.state_group || undefined, - state: filters?.state || undefined, - assignees: filters?.assignees || undefined, - mentions: filters?.mentions || undefined, - created_by: filters?.created_by || undefined, - labels: filters?.labels || undefined, - cycle: filters?.cycle || undefined, - module: filters?.module || undefined, - start_date: filters?.start_date || undefined, - target_date: filters?.target_date || undefined, - project: filters?.project || undefined, - team_project: filters?.team_project || undefined, - subscriber: filters?.subscriber || undefined, - issue_type: filters?.issue_type || undefined, - // display filters + ): Partial> => { + const computedDisplayFilters: Partial> = { group_by: displayFilters?.group_by ? EIssueGroupByToServerOptions[displayFilters.group_by] : undefined, sub_group_by: displayFilters?.sub_group_by ? EIssueGroupByToServerOptions[displayFilters.sub_group_by] : undefined, order_by: displayFilters?.order_by || undefined, - type: displayFilters?.type || undefined, sub_issue: displayFilters?.sub_issue ?? true, }; const issueFiltersParams: Partial> = {}; - Object.keys(computedFilters).forEach((key) => { + Object.keys(computedDisplayFilters).forEach((key) => { const _key = key as TIssueParams; - const _value: string | boolean | string[] | undefined = computedFilters[_key]; + const _value: string | boolean | string[] | undefined = computedDisplayFilters[_key]; const nonEmptyArrayValue = Array.isArray(_value) && _value.length === 0 ? undefined : _value; if (nonEmptyArrayValue != undefined && acceptableParamsByLayout.includes(_key)) issueFiltersParams[_key] = Array.isArray(nonEmptyArrayValue) @@ -120,9 +107,12 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { : nonEmptyArrayValue; }); + // work item filters + if (richFilters) issueFiltersParams.filters = JSON.stringify(richFilters); + if (displayFilters?.layout) issueFiltersParams.layout = displayFilters?.layout; - if (ENABLE_ISSUE_DEPENDENCIES && displayFilters.layout === EIssueLayoutTypes.GANTT) + if (ENABLE_ISSUE_DEPENDENCIES && displayFilters?.layout === EIssueLayoutTypes.GANTT) issueFiltersParams["expand"] = "issue_relation,issue_related"; return issueFiltersParams; @@ -152,35 +142,29 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { }); /** - * This PR is to get the filters of the fixed global views - * @param currentUserId current logged in user id - * @param type fixed view type - * @returns filterOptions based on views + * @description This method is used to get the filter conditions based on the views + * @param currentUserId + * @param type + * @returns */ - getComputedFiltersBasedOnViews = (currentUserId: string | undefined, type: TStaticViewTypes) => { - const noFilters = this.computedFilters({}); - - if (!currentUserId) return noFilters; - + getFilterConditionBasedOnViews: IIssueFilterHelperStore["getFilterConditionBasedOnViews"] = (currentUserId, type) => { + if (!currentUserId) return undefined; switch (type) { case "assigned": return { - ...noFilters, - assignees: [currentUserId], + assignees: currentUserId, }; case "created": return { - ...noFilters, - created_by: [currentUserId], + created_by: currentUserId, }; case "subscribed": return { - ...noFilters, - subscriber: [currentUserId], + subscriber: currentUserId, }; case "all-issues": default: - return noFilters; + return undefined; } }; @@ -257,7 +241,7 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { ...storageFilters[currentFilterIndex], filters: { ...storageFilters[currentFilterIndex].filters, - [filterType]: filters[filterType], + [filterType]: filters[filterType as keyof IIssueFiltersResponse], }, }; // All group_by "filters" are stored in a single array, will cause inconsistency in case of duplicated values diff --git a/apps/web/core/store/issue/issue-details/sub_issues.store.ts b/apps/web/core/store/issue/issue-details/sub_issues.store.ts index 2043db8b1..c461ea72a 100644 --- a/apps/web/core/store/issue/issue-details/sub_issues.store.ts +++ b/apps/web/core/store/issue/issue-details/sub_issues.store.ts @@ -126,7 +126,6 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { fetchSubIssues = async (workspaceSlug: string, projectId: string, parentIssueId: string) => { this.loader = "init-loader"; - const response = await this.issueService.subIssues(workspaceSlug, projectId, parentIssueId); const subIssuesStateDistribution = response?.state_distribution ?? {}; diff --git a/apps/web/core/store/issue/issue-details/sub_issues_filter.store.ts b/apps/web/core/store/issue/issue-details/sub_issues_filter.store.ts index d68583b68..1338f79b2 100644 --- a/apps/web/core/store/issue/issue-details/sub_issues_filter.store.ts +++ b/apps/web/core/store/issue/issue-details/sub_issues_filter.store.ts @@ -6,11 +6,11 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, - IIssueFilters, + ISubWorkItemFilters, TGroupedIssues, TIssue, } from "@plane/types"; -import { getFilteredWorkItems, getGroupedWorkItemIds, updateFilters } from "../helpers/base-issues-utils"; +import { getFilteredWorkItems, getGroupedWorkItemIds, updateSubWorkItemFilters } from "../helpers/base-issues-utils"; import { IssueSubIssuesStore } from "./sub_issues.store"; export const DEFAULT_DISPLAY_PROPERTIES = { @@ -23,9 +23,8 @@ export const DEFAULT_DISPLAY_PROPERTIES = { priority: true, state: true, }; - export interface IWorkItemSubIssueFiltersStore { - subIssueFilters: Record>; + subIssueFilters: Record>; // helpers methods updateSubWorkItemFilters: ( filterType: EIssueFilterType, @@ -34,13 +33,13 @@ export interface IWorkItemSubIssueFiltersStore { ) => void; getGroupedSubWorkItems: (workItemId: string) => TGroupedIssues; getFilteredSubWorkItems: (workItemId: string, filters: IIssueFilterOptions) => TIssue[]; - getSubIssueFilters: (workItemId: string) => Partial; + getSubIssueFilters: (workItemId: string) => Partial; resetFilters: (workItemId: string) => void; } export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersStore { // observables - subIssueFilters: Record> = {}; + subIssueFilters: Record> = {}; // root store subIssueStore: IssueSubIssuesStore; @@ -89,7 +88,7 @@ export class WorkItemSubIssueFiltersStore implements IWorkItemSubIssueFiltersSto workItemId: string ) => { runInAction(() => { - updateFilters(this.subIssueFilters, filterType, filters, workItemId); + updateSubWorkItemFilters(this.subIssueFilters, filterType, filters, workItemId); }); }; diff --git a/apps/web/core/store/issue/module/filter.store.ts b/apps/web/core/store/issue/module/filter.store.ts index 58e4f2a1e..228e5e728 100644 --- a/apps/web/core/store/issue/module/filter.store.ts +++ b/apps/web/core/store/issue/module/filter.store.ts @@ -3,16 +3,17 @@ import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // base class import { computedFn } from "mobx-utils"; -import { EIssueFilterType } from "@plane/constants"; +import { EIssueFilterType, TSupportedFilterTypeForUpdate } from "@plane/constants"; import { EIssuesStoreType, - IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueKanbanFilters, IIssueFilters, TIssueParams, IssuePaginationOptions, + TWorkItemFilterExpression, + TSupportedFilterForUpdate, } from "@plane/types"; import { handleIssueQueryParamsByLayout } from "@plane/utils"; import { IssueFiltersService } from "@/services/issue_filter.service"; @@ -35,11 +36,17 @@ export interface IModuleIssuesFilter extends IBaseIssueFilterStore { getIssueFilters(moduleId: string): IIssueFilters | undefined; // action fetchFilters: (workspaceSlug: string, projectId: string, moduleId: string) => Promise; + updateFilterExpression: ( + workspaceSlug: string, + projectId: string, + moduleId: string, + filters: TWorkItemFilterExpression + ) => Promise; updateFilters: ( workspaceSlug: string, projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, + filterType: TSupportedFilterTypeForUpdate, + filters: TSupportedFilterForUpdate, moduleId: string ) => Promise; } @@ -103,8 +110,8 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul if (filteredParams.includes("module")) filteredParams.splice(filteredParams.indexOf("module"), 1); const filteredRouteParams: Partial> = this.computedFilteredParams( - userFilters?.filters as IIssueFilterOptions, - userFilters?.displayFilters as IIssueDisplayFilterOptions, + userFilters?.richFilters, + userFilters?.displayFilters, filteredParams ); @@ -132,79 +139,80 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul ); fetchFilters = async (workspaceSlug: string, projectId: string, moduleId: string) => { + const _filters = await this.issueFilterService.fetchModuleIssueFilters(workspaceSlug, projectId, moduleId); + + const richFilters: TWorkItemFilterExpression = _filters?.rich_filters; + const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); + const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); + + // fetching the kanban toggle helpers in the local storage + const kanbanFilters = { + group_by: [], + sub_group_by: [], + }; + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) { + const _kanbanFilters = this.handleIssuesLocalFilters.get( + EIssuesStoreType.MODULE, + workspaceSlug, + moduleId, + currentUserId + ); + kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || []; + kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || []; + } + + runInAction(() => { + set(this.filters, [moduleId, "richFilters"], richFilters); + set(this.filters, [moduleId, "displayFilters"], displayFilters); + set(this.filters, [moduleId, "displayProperties"], displayProperties); + set(this.filters, [moduleId, "kanbanFilters"], kanbanFilters); + }); + }; + + /** + * NOTE: This method is designed as a fallback function for the work item filter store. + * Only use this method directly when initializing filter instances. + * For regular filter updates, use this method as a fallback function for the work item filter store methods instead. + */ + updateFilterExpression: IModuleIssuesFilter["updateFilterExpression"] = async ( + workspaceSlug, + projectId, + moduleId, + filters + ) => { try { - const _filters = await this.issueFilterService.fetchModuleIssueFilters(workspaceSlug, projectId, moduleId); - - const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters); - const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); - const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); - - // fetching the kanban toggle helpers in the local storage - const kanbanFilters = { - group_by: [], - sub_group_by: [], - }; - const currentUserId = this.rootIssueStore.currentUserId; - if (currentUserId) { - const _kanbanFilters = this.handleIssuesLocalFilters.get( - EIssuesStoreType.MODULE, - workspaceSlug, - moduleId, - currentUserId - ); - kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || []; - kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || []; - } - runInAction(() => { - set(this.filters, [moduleId, "filters"], filters); - set(this.filters, [moduleId, "displayFilters"], displayFilters); - set(this.filters, [moduleId, "displayProperties"], displayProperties); - set(this.filters, [moduleId, "kanbanFilters"], kanbanFilters); + set(this.filters, [moduleId, "richFilters"], filters); + }); + + this.rootIssueStore.moduleIssues.fetchIssuesWithExistingPagination( + workspaceSlug, + projectId, + "mutation", + moduleId + ); + await this.issueFilterService.patchModuleIssueFilters(workspaceSlug, projectId, moduleId, { + rich_filters: filters, }); } catch (error) { + console.log("error while updating rich filters", error); throw error; } }; - updateFilters = async ( - workspaceSlug: string, - projectId: string, - type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, - moduleId: string - ) => { + updateFilters: IModuleIssuesFilter["updateFilters"] = async (workspaceSlug, projectId, type, filters, moduleId) => { try { - if (isEmpty(this.filters) || isEmpty(this.filters[moduleId]) || isEmpty(filters)) return; + if (isEmpty(this.filters) || isEmpty(this.filters[moduleId])) return; const _filters = { - filters: this.filters[moduleId].filters as IIssueFilterOptions, + richFilters: this.filters[moduleId].richFilters as TWorkItemFilterExpression, displayFilters: this.filters[moduleId].displayFilters as IIssueDisplayFilterOptions, displayProperties: this.filters[moduleId].displayProperties as IIssueDisplayProperties, kanbanFilters: this.filters[moduleId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { - case EIssueFilterType.FILTERS: { - const updatedFilters = filters as IIssueFilterOptions; - _filters.filters = { ..._filters.filters, ...updatedFilters }; - - runInAction(() => { - Object.keys(updatedFilters).forEach((_key) => { - set(this.filters, [moduleId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); - }); - }); - this.rootIssueStore.moduleIssues.fetchIssuesWithExistingPagination( - workspaceSlug, - projectId, - "mutation", - moduleId - ); - await this.issueFilterService.patchModuleIssueFilters(workspaceSlug, projectId, moduleId, { - filters: _filters.filters, - }); - break; - } case EIssueFilterType.DISPLAY_FILTERS: { const updatedDisplayFilters = filters as IIssueDisplayFilterOptions; _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; diff --git a/apps/web/core/store/issue/profile/filter.store.ts b/apps/web/core/store/issue/profile/filter.store.ts index 65b6ec7a6..2d2b040e5 100644 --- a/apps/web/core/store/issue/profile/filter.store.ts +++ b/apps/web/core/store/issue/profile/filter.store.ts @@ -3,16 +3,17 @@ import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // base class import { computedFn } from "mobx-utils"; -import { EIssueFilterType } from "@plane/constants"; +import { EIssueFilterType, TSupportedFilterTypeForUpdate } from "@plane/constants"; import { EIssuesStoreType, - IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueKanbanFilters, IIssueFilters, TIssueParams, IssuePaginationOptions, + TWorkItemFilterExpression, + TSupportedFilterForUpdate, } from "@plane/types"; import { handleIssueQueryParamsByLayout } from "@plane/utils"; import { IssueFiltersService } from "@/services/issue_filter.service"; @@ -36,11 +37,12 @@ export interface IProfileIssuesFilter extends IBaseIssueFilterStore { ) => Partial>; // action fetchFilters: (workspaceSlug: string, userId: string) => Promise; + updateFilterExpression: (workspaceSlug: string, userId: string, filters: TWorkItemFilterExpression) => Promise; updateFilters: ( workspaceSlug: string, projectId: string | undefined, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, + filterType: TSupportedFilterTypeForUpdate, + filters: TSupportedFilterForUpdate, userId: string ) => Promise; } @@ -104,8 +106,8 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf if (!filteredParams) return undefined; const filteredRouteParams: Partial> = this.computedFilteredParams( - userFilters?.filters as IIssueFilterOptions, - userFilters?.displayFilters as IIssueDisplayFilterOptions, + userFilters?.richFilters, + userFilters?.displayFilters, filteredParams ); @@ -128,64 +130,65 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf ); fetchFilters = async (workspaceSlug: string, userId: string) => { + this.userId = userId; + const _filters = this.handleIssuesLocalFilters.get(EIssuesStoreType.PROFILE, workspaceSlug, userId, undefined); + + const richFilters: TWorkItemFilterExpression = _filters?.rich_filters; + const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); + const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); + const kanbanFilters = { + group_by: _filters?.kanban_filters?.group_by || [], + sub_group_by: _filters?.kanban_filters?.sub_group_by || [], + }; + + runInAction(() => { + set(this.filters, [userId, "richFilters"], richFilters); + set(this.filters, [userId, "displayFilters"], displayFilters); + set(this.filters, [userId, "displayProperties"], displayProperties); + set(this.filters, [userId, "kanbanFilters"], kanbanFilters); + }); + }; + + /** + * NOTE: This method is designed as a fallback function for the work item filter store. + * Only use this method directly when initializing filter instances. + * For regular filter updates, use this method as a fallback function for the work item filter store methods instead. + */ + updateFilterExpression: IProfileIssuesFilter["updateFilterExpression"] = async (workspaceSlug, userId, filters) => { try { - this.userId = userId; - const _filters = this.handleIssuesLocalFilters.get(EIssuesStoreType.PROFILE, workspaceSlug, userId, undefined); - - const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters); - const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); - const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); - const kanbanFilters = { - group_by: _filters?.kanban_filters?.group_by || [], - sub_group_by: _filters?.kanban_filters?.sub_group_by || [], - }; - runInAction(() => { - set(this.filters, [userId, "filters"], filters); - set(this.filters, [userId, "displayFilters"], displayFilters); - set(this.filters, [userId, "displayProperties"], displayProperties); - set(this.filters, [userId, "kanbanFilters"], kanbanFilters); + set(this.filters, [userId, "richFilters"], filters); }); + + this.rootIssueStore.profileIssues.fetchIssuesWithExistingPagination(workspaceSlug, userId, "mutation"); + this.handleIssuesLocalFilters.set( + EIssuesStoreType.PROFILE, + EIssueFilterType.FILTERS, + workspaceSlug, + userId, + undefined, + { + rich_filters: filters, + } + ); } catch (error) { + console.log("error while updating rich filters", error); throw error; } }; - updateFilters = async ( - workspaceSlug: string, - projectId: string | undefined, - type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, - userId: string - ) => { + updateFilters: IProfileIssuesFilter["updateFilters"] = async (workspaceSlug, _projectId, type, filters, userId) => { try { - if (isEmpty(this.filters) || isEmpty(this.filters[userId]) || isEmpty(filters)) return; + if (isEmpty(this.filters) || isEmpty(this.filters[userId])) return; const _filters = { - filters: this.filters[userId].filters as IIssueFilterOptions, + richFilters: this.filters[userId].richFilters as TWorkItemFilterExpression, displayFilters: this.filters[userId].displayFilters as IIssueDisplayFilterOptions, displayProperties: this.filters[userId].displayProperties as IIssueDisplayProperties, kanbanFilters: this.filters[userId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { - case EIssueFilterType.FILTERS: { - const updatedFilters = filters as IIssueFilterOptions; - _filters.filters = { ..._filters.filters, ...updatedFilters }; - - runInAction(() => { - Object.keys(updatedFilters).forEach((_key) => { - set(this.filters, [userId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); - }); - }); - - this.rootIssueStore.profileIssues.fetchIssuesWithExistingPagination(workspaceSlug, userId, "mutation"); - - this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, { - filters: _filters.filters, - }); - break; - } case EIssueFilterType.DISPLAY_FILTERS: { const updatedDisplayFilters = filters as IIssueDisplayFilterOptions; _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; diff --git a/apps/web/core/store/issue/profile/issue.store.ts b/apps/web/core/store/issue/profile/issue.store.ts index b26dda529..366e7ff6a 100644 --- a/apps/web/core/store/issue/profile/issue.store.ts +++ b/apps/web/core/store/issue/profile/issue.store.ts @@ -7,6 +7,7 @@ import { TIssuesResponse, ViewFlags, TBulkOperationsPayload, + TProfileViews, } from "@plane/types"; import { UserService } from "@/services/user.service"; @@ -18,17 +19,18 @@ import { IProfileIssuesFilter } from "./filter.store"; export interface IProfileIssues extends IBaseIssuesStore { // observable - currentView: "assigned" | "created" | "subscribed"; + currentView: TProfileViews; viewFlags: ViewFlags; // actions - setViewId: (viewId: "assigned" | "created" | "subscribed") => void; + setViewId: (viewId: TProfileViews) => void; // action fetchIssues: ( workspaceSlug: string, userId: string, loadType: TLoader, option: IssuePaginationOptions, - view: "assigned" | "created" | "subscribed" + view: TProfileViews, + isExistingPaginationOptions?: boolean ) => Promise; fetchIssuesWithExistingPagination: ( workspaceSlug: string, @@ -53,7 +55,7 @@ export interface IProfileIssues extends IBaseIssuesStore { } export class ProfileIssues extends BaseIssuesStore implements IProfileIssues { - currentView: "assigned" | "created" | "subscribed" = "assigned"; + currentView: TProfileViews = "assigned"; // filter store issueFilterStore: IProfileIssuesFilter; // services @@ -92,7 +94,7 @@ export class ProfileIssues extends BaseIssuesStore implements IProfileIssues { }; } - setViewId(viewId: "assigned" | "created" | "subscribed") { + setViewId(viewId: TProfileViews) { this.currentView = viewId; } @@ -110,12 +112,12 @@ export class ProfileIssues extends BaseIssuesStore implements IProfileIssues { * @param view * @returns */ - fetchIssues = async ( + fetchIssues: IProfileIssues["fetchIssues"] = async ( workspaceSlug: string, userId: string, loadType: TLoader, options: IssuePaginationOptions, - view: "assigned" | "created" | "subscribed", + view: TProfileViews, isExistingPaginationOptions: boolean = false ) => { try { diff --git a/apps/web/core/store/issue/project-views/filter.store.ts b/apps/web/core/store/issue/project-views/filter.store.ts index 63b45c793..840c8b9b3 100644 --- a/apps/web/core/store/issue/project-views/filter.store.ts +++ b/apps/web/core/store/issue/project-views/filter.store.ts @@ -3,16 +3,18 @@ import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // base class import { computedFn } from "mobx-utils"; -import { EIssueFilterType } from "@plane/constants"; +import { EIssueFilterType, TSupportedFilterTypeForUpdate } from "@plane/constants"; import { EIssuesStoreType, - IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueKanbanFilters, IIssueFilters, TIssueParams, IssuePaginationOptions, + IProjectView, + TWorkItemFilterExpression, + TSupportedFilterForUpdate, } from "@plane/types"; import { handleIssueQueryParamsByLayout } from "@plane/utils"; // services @@ -33,15 +35,24 @@ export interface IProjectViewIssuesFilter extends IBaseIssueFilterStore { subGroupId: string | undefined ) => Partial>; getIssueFilters(viewId: string): IIssueFilters | undefined; + // helper actions + mutateFilters: (workspaceSlug: string, viewId: string, viewDetails: IProjectView) => void; // action fetchFilters: (workspaceSlug: string, projectId: string, viewId: string) => Promise; + updateFilterExpression: ( + workspaceSlug: string, + projectId: string, + viewId: string, + filters: TWorkItemFilterExpression + ) => Promise; updateFilters: ( workspaceSlug: string, projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, + filterType: TSupportedFilterTypeForUpdate, + filters: TSupportedFilterForUpdate, viewId: string ) => Promise; + resetFilters: (workspaceSlug: string, viewId: string) => void; } export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements IProjectViewIssuesFilter { @@ -63,6 +74,7 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I // actions fetchFilters: action, updateFilters: action, + resetFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -101,8 +113,8 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I if (!filteredParams) return undefined; const filteredRouteParams: Partial> = this.computedFilteredParams( - userFilters?.filters as IIssueFilterOptions, - userFilters?.displayFilters as IIssueDisplayFilterOptions, + userFilters?.richFilters, + userFilters?.displayFilters, filteredParams ); @@ -124,78 +136,92 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I } ); + mutateFilters: IProjectViewIssuesFilter["mutateFilters"] = action((workspaceSlug, viewId, viewDetails) => { + const richFilters: TWorkItemFilterExpression = viewDetails?.rich_filters; + const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(viewDetails?.display_filters); + const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(viewDetails?.display_properties); + + // fetching the kanban toggle helpers in the local storage + const kanbanFilters = { + group_by: [], + sub_group_by: [], + }; + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) { + const _kanbanFilters = this.handleIssuesLocalFilters.get( + EIssuesStoreType.PROJECT_VIEW, + workspaceSlug, + viewId, + currentUserId + ); + kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || []; + kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || []; + } + + runInAction(() => { + set(this.filters, [viewId, "richFilters"], richFilters); + set(this.filters, [viewId, "displayFilters"], displayFilters); + set(this.filters, [viewId, "displayProperties"], displayProperties); + set(this.filters, [viewId, "kanbanFilters"], kanbanFilters); + }); + }); + fetchFilters = async (workspaceSlug: string, projectId: string, viewId: string) => { try { - const _filters = await this.issueFilterService.getViewDetails(workspaceSlug, projectId, viewId); - - const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters); - const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); - const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); - - // fetching the kanban toggle helpers in the local storage - const kanbanFilters = { - group_by: [], - sub_group_by: [], - }; - const currentUserId = this.rootIssueStore.currentUserId; - if (currentUserId) { - const _kanbanFilters = this.handleIssuesLocalFilters.get( - EIssuesStoreType.PROJECT_VIEW, - workspaceSlug, - viewId, - currentUserId - ); - kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || []; - kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || []; - } - - runInAction(() => { - set(this.filters, [viewId, "filters"], filters); - set(this.filters, [viewId, "displayFilters"], displayFilters); - set(this.filters, [viewId, "displayProperties"], displayProperties); - set(this.filters, [viewId, "kanbanFilters"], kanbanFilters); - }); + const viewDetails = await this.issueFilterService.getViewDetails(workspaceSlug, projectId, viewId); + this.mutateFilters(workspaceSlug, viewId, viewDetails); } catch (error) { + console.log("error while fetching project view filters", error); throw error; } }; - updateFilters = async ( - workspaceSlug: string, - projectId: string, - type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, - viewId: string + /** + * NOTE: This method is designed as a fallback function for the work item filter store. + * Only use this method directly when initializing filter instances. + * For regular filter updates, use this method as a fallback function for the work item filter store methods instead. + */ + updateFilterExpression: IProjectViewIssuesFilter["updateFilterExpression"] = async ( + workspaceSlug, + projectId, + viewId, + filters ) => { try { - if (isEmpty(this.filters) || isEmpty(this.filters[viewId]) || isEmpty(filters)) return; + runInAction(() => { + set(this.filters, [viewId, "richFilters"], filters); + }); + + this.rootIssueStore.projectViewIssues.fetchIssuesWithExistingPagination( + workspaceSlug, + projectId, + viewId, + "mutation" + ); + } catch (error) { + console.log("error while updating rich filters", error); + throw error; + } + }; + + updateFilters: IProjectViewIssuesFilter["updateFilters"] = async ( + workspaceSlug, + projectId, + type, + filters, + viewId + ) => { + try { + if (isEmpty(this.filters) || isEmpty(this.filters[viewId])) return; const _filters = { - filters: this.filters[viewId].filters as IIssueFilterOptions, + richFilters: this.filters[viewId].richFilters as TWorkItemFilterExpression, displayFilters: this.filters[viewId].displayFilters as IIssueDisplayFilterOptions, displayProperties: this.filters[viewId].displayProperties as IIssueDisplayProperties, kanbanFilters: this.filters[viewId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { - case EIssueFilterType.FILTERS: { - const updatedFilters = filters as IIssueFilterOptions; - _filters.filters = { ..._filters.filters, ...updatedFilters }; - - runInAction(() => { - Object.keys(updatedFilters).forEach((_key) => { - set(this.filters, [viewId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); - }); - }); - - this.rootIssueStore.projectViewIssues.fetchIssuesWithExistingPagination( - workspaceSlug, - projectId, - viewId, - "mutation" - ); - break; - } case EIssueFilterType.DISPLAY_FILTERS: { const updatedDisplayFilters = filters as IIssueDisplayFilterOptions; _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; @@ -297,4 +323,15 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I throw error; } }; + + /** + * @description resets the filters for a project view + * @param workspaceSlug + * @param viewId + */ + resetFilters: IProjectViewIssuesFilter["resetFilters"] = action((workspaceSlug, viewId) => { + const viewDetails = this.rootIssueStore.rootStore.projectView.getViewById(viewId); + if (!viewDetails) return; + this.mutateFilters(workspaceSlug, viewId, viewDetails); + }); } diff --git a/apps/web/core/store/issue/project/filter.store.ts b/apps/web/core/store/issue/project/filter.store.ts index eeaea4677..473dc0d30 100644 --- a/apps/web/core/store/issue/project/filter.store.ts +++ b/apps/web/core/store/issue/project/filter.store.ts @@ -3,16 +3,17 @@ import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // base class import { computedFn } from "mobx-utils"; -import { EIssueFilterType } from "@plane/constants"; +import { EIssueFilterType, TSupportedFilterTypeForUpdate } from "@plane/constants"; import { EIssuesStoreType, - IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueKanbanFilters, IIssueFilters, TIssueParams, IssuePaginationOptions, + TWorkItemFilterExpression, + TSupportedFilterForUpdate, } from "@plane/types"; import { handleIssueQueryParamsByLayout } from "@plane/utils"; import { IssueFiltersService } from "@/services/issue_filter.service"; @@ -35,11 +36,16 @@ export interface IProjectIssuesFilter extends IBaseIssueFilterStore { getIssueFilters(projectId: string): IIssueFilters | undefined; // action fetchFilters: (workspaceSlug: string, projectId: string) => Promise; + updateFilterExpression: ( + workspaceSlug: string, + projectId: string, + filters: TWorkItemFilterExpression + ) => Promise; updateFilters: ( workspaceSlug: string, projectId: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters + filterType: TSupportedFilterTypeForUpdate, + filters: TSupportedFilterForUpdate ) => Promise; } @@ -61,6 +67,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj appliedFilters: computed, // actions fetchFilters: action, + updateFilterExpression: action, updateFilters: action, }); // root store @@ -98,8 +105,8 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj if (!filteredParams) return undefined; const filteredRouteParams: Partial> = this.computedFilteredParams( - userFilters?.filters as IIssueFilterOptions, - userFilters?.displayFilters as IIssueDisplayFilterOptions, + userFilters?.richFilters, + userFilters?.displayFilters, filteredParams ); @@ -121,74 +128,74 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj ); fetchFilters = async (workspaceSlug: string, projectId: string) => { + const _filters = await this.issueFilterService.fetchProjectIssueFilters(workspaceSlug, projectId); + + const richFilters = _filters?.rich_filters; + const displayFilters = this.computedDisplayFilters(_filters?.display_filters); + const displayProperties = this.computedDisplayProperties(_filters?.display_properties); + + // fetching the kanban toggle helpers in the local storage + const kanbanFilters = { + group_by: [], + sub_group_by: [], + }; + const currentUserId = this.rootIssueStore.currentUserId; + if (currentUserId) { + const _kanbanFilters = this.handleIssuesLocalFilters.get( + EIssuesStoreType.PROJECT, + workspaceSlug, + projectId, + currentUserId + ); + kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || []; + kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || []; + } + + runInAction(() => { + set(this.filters, [projectId, "richFilters"], richFilters); + set(this.filters, [projectId, "displayFilters"], displayFilters); + set(this.filters, [projectId, "displayProperties"], displayProperties); + set(this.filters, [projectId, "kanbanFilters"], kanbanFilters); + }); + }; + + /** + * NOTE: This method is designed as a fallback function for the work item filter store. + * Only use this method directly when initializing filter instances. + * For regular filter updates, use this method as a fallback function for the work item filter store methods instead. + */ + updateFilterExpression: IProjectIssuesFilter["updateFilterExpression"] = async ( + workspaceSlug, + projectId, + filters + ) => { try { - const _filters = await this.issueFilterService.fetchProjectIssueFilters(workspaceSlug, projectId); - - const filters = this.computedFilters(_filters?.filters); - const displayFilters = this.computedDisplayFilters(_filters?.display_filters); - const displayProperties = this.computedDisplayProperties(_filters?.display_properties); - - // fetching the kanban toggle helpers in the local storage - const kanbanFilters = { - group_by: [], - sub_group_by: [], - }; - const currentUserId = this.rootIssueStore.currentUserId; - if (currentUserId) { - const _kanbanFilters = this.handleIssuesLocalFilters.get( - EIssuesStoreType.PROJECT, - workspaceSlug, - projectId, - currentUserId - ); - kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || []; - kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || []; - } - runInAction(() => { - set(this.filters, [projectId, "filters"], filters); - set(this.filters, [projectId, "displayFilters"], displayFilters); - set(this.filters, [projectId, "displayProperties"], displayProperties); - set(this.filters, [projectId, "kanbanFilters"], kanbanFilters); + set(this.filters, [projectId, "richFilters"], filters); + }); + + this.rootIssueStore.projectIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation"); + await this.issueFilterService.patchProjectIssueFilters(workspaceSlug, projectId, { + rich_filters: filters, }); } catch (error) { + console.log("error while updating rich filters", error); throw error; } }; - updateFilters = async ( - workspaceSlug: string, - projectId: string, - type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters - ) => { + updateFilters: IProjectIssuesFilter["updateFilters"] = async (workspaceSlug, projectId, type, filters) => { try { - if (isEmpty(this.filters) || isEmpty(this.filters[projectId]) || isEmpty(filters)) return; + if (isEmpty(this.filters) || isEmpty(this.filters[projectId])) return; const _filters = { - filters: this.filters[projectId].filters as IIssueFilterOptions, + richFilters: this.filters[projectId].richFilters as TWorkItemFilterExpression, displayFilters: this.filters[projectId].displayFilters as IIssueDisplayFilterOptions, displayProperties: this.filters[projectId].displayProperties as IIssueDisplayProperties, kanbanFilters: this.filters[projectId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { - case EIssueFilterType.FILTERS: { - const updatedFilters = filters as IIssueFilterOptions; - _filters.filters = { ..._filters.filters, ...updatedFilters }; - - runInAction(() => { - Object.keys(updatedFilters).forEach((_key) => { - set(this.filters, [projectId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); - }); - }); - - this.rootIssueStore.projectIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation"); - await this.issueFilterService.patchProjectIssueFilters(workspaceSlug, projectId, { - filters: _filters.filters, - }); - break; - } case EIssueFilterType.DISPLAY_FILTERS: { const updatedDisplayFilters = filters as IIssueDisplayFilterOptions; _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; diff --git a/apps/web/core/store/issue/workspace-draft/filter.store.ts b/apps/web/core/store/issue/workspace-draft/filter.store.ts index 8c6a557a5..330f543de 100644 --- a/apps/web/core/store/issue/workspace-draft/filter.store.ts +++ b/apps/web/core/store/issue/workspace-draft/filter.store.ts @@ -3,16 +3,17 @@ import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // base class import { computedFn } from "mobx-utils"; -import { EIssueFilterType } from "@plane/constants"; +import { EIssueFilterType, TSupportedFilterTypeForUpdate } from "@plane/constants"; import { EIssuesStoreType, - IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueKanbanFilters, IIssueFilters, TIssueParams, IssuePaginationOptions, + TWorkItemFilterExpression, + TSupportedFilterForUpdate, } from "@plane/types"; import { handleIssueQueryParamsByLayout } from "@plane/utils"; import { IssueFiltersService } from "@/services/issue_filter.service"; @@ -36,10 +37,11 @@ export interface IWorkspaceDraftIssuesFilter extends IBaseIssueFilterStore { ) => Partial>; // action fetchFilters: (workspaceSlug: string) => Promise; + updateFilterExpression: (workspaceSlug: string, userId: string, filters: TWorkItemFilterExpression) => Promise; updateFilters: ( workspaceSlug: string, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters + filterType: TSupportedFilterTypeForUpdate, + filters: TSupportedFilterForUpdate ) => Promise; } @@ -102,8 +104,8 @@ export class WorkspaceDraftIssuesFilter extends IssueFilterHelperStore implement if (!filteredParams) return undefined; const filteredRouteParams: Partial> = this.computedFilteredParams( - userFilters?.filters as IIssueFilterOptions, - userFilters?.displayFilters as IIssueDisplayFilterOptions, + userFilters?.richFilters, + userFilters?.displayFilters, filteredParams ); @@ -134,7 +136,7 @@ export class WorkspaceDraftIssuesFilter extends IssueFilterHelperStore implement undefined ); - const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters); + const richFilters: TWorkItemFilterExpression = _filters?.rich_filters; const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); const kanbanFilters = { @@ -143,46 +145,57 @@ export class WorkspaceDraftIssuesFilter extends IssueFilterHelperStore implement }; runInAction(() => { - set(this.filters, [workspaceSlug, "filters"], filters); + set(this.filters, [workspaceSlug, "richFilters"], richFilters); set(this.filters, [workspaceSlug, "displayFilters"], displayFilters); set(this.filters, [workspaceSlug, "displayProperties"], displayProperties); set(this.filters, [workspaceSlug, "kanbanFilters"], kanbanFilters); }); }; - updateFilters = async ( - workspaceSlug: string, - type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters + /** + * NOTE: This method is designed as a fallback function for the work item filter store. + * Only use this method directly when initializing filter instances. + * For regular filter updates, use this method as a fallback function for the work item filter store methods instead. + */ + updateFilterExpression: IWorkspaceDraftIssuesFilter["updateFilterExpression"] = async ( + workspaceSlug, + userId, + filters ) => { try { - if (isEmpty(this.filters) || isEmpty(this.filters[workspaceSlug]) || isEmpty(filters)) return; + runInAction(() => { + set(this.filters, [workspaceSlug, "richFilters"], filters); + }); + + this.rootIssueStore.profileIssues.fetchIssuesWithExistingPagination(workspaceSlug, workspaceSlug, "mutation"); + this.handleIssuesLocalFilters.set( + EIssuesStoreType.PROFILE, + EIssueFilterType.FILTERS, + workspaceSlug, + workspaceSlug, + undefined, + { + rich_filters: filters, + } + ); + } catch (error) { + console.log("error while updating rich filters", error); + throw error; + } + }; + + updateFilters: IWorkspaceDraftIssuesFilter["updateFilters"] = async (workspaceSlug, type, filters) => { + try { + if (isEmpty(this.filters) || isEmpty(this.filters[workspaceSlug])) return; const _filters = { - filters: this.filters[workspaceSlug].filters as IIssueFilterOptions, + richFilters: this.filters[workspaceSlug].richFilters as TWorkItemFilterExpression, displayFilters: this.filters[workspaceSlug].displayFilters as IIssueDisplayFilterOptions, displayProperties: this.filters[workspaceSlug].displayProperties as IIssueDisplayProperties, kanbanFilters: this.filters[workspaceSlug].kanbanFilters as TIssueKanbanFilters, }; switch (type) { - case EIssueFilterType.FILTERS: { - const updatedFilters = filters as IIssueFilterOptions; - _filters.filters = { ..._filters.filters, ...updatedFilters }; - - runInAction(() => { - Object.keys(updatedFilters).forEach((_key) => { - set(this.filters, [workspaceSlug, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); - }); - }); - - this.rootIssueStore.profileIssues.fetchIssuesWithExistingPagination(workspaceSlug, workspaceSlug, "mutation"); - - this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, workspaceSlug, undefined, { - filters: _filters.filters, - }); - break; - } case EIssueFilterType.DISPLAY_FILTERS: { const updatedDisplayFilters = filters as IIssueDisplayFilterOptions; _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; diff --git a/apps/web/core/store/issue/workspace/filter.store.ts b/apps/web/core/store/issue/workspace/filter.store.ts index 46088ef1a..e47be04db 100644 --- a/apps/web/core/store/issue/workspace/filter.store.ts +++ b/apps/web/core/store/issue/workspace/filter.store.ts @@ -1,15 +1,11 @@ -import isArray from "lodash/isArray"; import isEmpty from "lodash/isEmpty"; -import pickBy from "lodash/pickBy"; import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; -// plane constants import { computedFn } from "mobx-utils"; -import { EIssueFilterType } from "@plane/constants"; -// base class +// plane imports +import { EIssueFilterType, TSupportedFilterTypeForUpdate } from "@plane/constants"; import { EIssuesStoreType, - IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueKanbanFilters, @@ -18,27 +14,34 @@ import { TStaticViewTypes, IssuePaginationOptions, EIssueLayoutTypes, + TWorkItemFilterExpression, + STATIC_VIEW_TYPES, + TSupportedFilterForUpdate, } from "@plane/types"; -// services import { handleIssueQueryParamsByLayout } from "@plane/utils"; -import { WorkspaceService } from "@/plane-web/services"; -import { IBaseIssueFilterStore, IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; -// helpers -// types -import { IIssueRootStore } from "../root.store"; -// constants // services +import { WorkspaceService } from "@/plane-web/services"; +// local imports +import { + IBaseIssueFilterStore, + IIssueFilterHelperStore, + IssueFilterHelperStore, +} from "../helpers/issue-filter-helper.store"; +import { IIssueRootStore } from "../root.store"; -type TWorkspaceFilters = "all-issues" | "assigned" | "created" | "subscribed" | string; +type TWorkspaceFilters = TStaticViewTypes | string; -export interface IWorkspaceIssuesFilter extends IBaseIssueFilterStore { +export type TBaseFilterStore = IBaseIssueFilterStore & IIssueFilterHelperStore; + +export interface IWorkspaceIssuesFilter extends TBaseFilterStore { // fetch action fetchFilters: (workspaceSlug: string, viewId: string) => Promise; + updateFilterExpression: (workspaceSlug: string, viewId: string, filters: TWorkItemFilterExpression) => Promise; updateFilters: ( workspaceSlug: string, projectId: string | undefined, - filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, + filterType: TSupportedFilterTypeForUpdate, + filters: TSupportedFilterForUpdate, viewId: string ) => Promise; //helper action @@ -100,8 +103,8 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo if (!filteredParams) return undefined; const filteredRouteParams: Partial> = this.computedFilteredParams( - userFilters?.filters as IIssueFilterOptions, - userFilters?.displayFilters as IIssueDisplayFilterOptions, + userFilters?.richFilters, + userFilters?.displayFilters, filteredParams ); @@ -126,7 +129,19 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo groupId: string | undefined, subGroupId: string | undefined ) => { - const filterParams = this.getAppliedFilters(viewId); + let filterParams = this.getAppliedFilters(viewId); + + if (!filterParams) { + filterParams = {}; + } + + if (STATIC_VIEW_TYPES.includes(viewId)) { + const currentUserId = this.rootIssueStore.currentUserId; + const paramForStaticView = this.getFilterConditionBasedOnViews(currentUserId, viewId as TStaticViewTypes); + if (paramForStaticView) { + filterParams = { ...filterParams, ...paramForStaticView }; + } + } const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId); return paginationParams; @@ -134,7 +149,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo ); fetchFilters = async (workspaceSlug: string, viewId: TWorkspaceFilters) => { - let filters: IIssueFilterOptions; + let richFilters: TWorkItemFilterExpression; let displayFilters: IIssueDisplayFilterOptions; let displayProperties: IIssueDisplayProperties; let kanbanFilters: TIssueKanbanFilters = { @@ -153,12 +168,10 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo sub_group_by: _filters?.kanban_filters?.sub_group_by || [], }; - if (["all-issues", "assigned", "created", "subscribed"].includes(viewId)) { - const currentUserId = this.rootIssueStore.currentUserId; - filters = this.getComputedFiltersBasedOnViews(currentUserId, viewId as TStaticViewTypes); - } else { + // Get the view details if the view is not a static view + if (STATIC_VIEW_TYPES.includes(viewId) === false) { const _filters = await this.issueFilterService.getViewDetails(workspaceSlug, viewId); - filters = this.computedFilters(_filters?.filters); + richFilters = _filters?.rich_filters; displayFilters = this.computedDisplayFilters(_filters?.display_filters, { layout: EIssueLayoutTypes.SPREADSHEET, order_by: "-created_at", @@ -172,51 +185,45 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo } runInAction(() => { - set(this.filters, [viewId, "filters"], filters); + set(this.filters, [viewId, "richFilters"], richFilters); set(this.filters, [viewId, "displayFilters"], displayFilters); set(this.filters, [viewId, "displayProperties"], displayProperties); set(this.filters, [viewId, "kanbanFilters"], kanbanFilters); }); }; - updateFilters = async ( - workspaceSlug: string, - projectId: string | undefined, - type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, - viewId: string - ) => { + /** + * NOTE: This method is designed as a fallback function for the work item filter store. + * Only use this method directly when initializing filter instances. + * For regular filter updates, use this method as a fallback function for the work item filter store methods instead. + */ + updateFilterExpression: IWorkspaceIssuesFilter["updateFilterExpression"] = async (workspaceSlug, viewId, filters) => { + try { + runInAction(() => { + set(this.filters, [viewId, "richFilters"], filters); + }); + + this.rootIssueStore.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation"); + } catch (error) { + console.log("error while updating rich filters", error); + throw error; + } + }; + + updateFilters: IWorkspaceIssuesFilter["updateFilters"] = async (workspaceSlug, projectId, type, filters, viewId) => { try { const issueFilters = this.getIssueFilters(viewId); - if (!issueFilters || isEmpty(filters)) return; + if (!issueFilters) return; const _filters = { - filters: issueFilters.filters as IIssueFilterOptions, + richFilters: issueFilters.richFilters as TWorkItemFilterExpression, displayFilters: issueFilters.displayFilters as IIssueDisplayFilterOptions, displayProperties: issueFilters.displayProperties as IIssueDisplayProperties, kanbanFilters: issueFilters.kanbanFilters as TIssueKanbanFilters, }; switch (type) { - case EIssueFilterType.FILTERS: { - const updatedFilters = filters as IIssueFilterOptions; - _filters.filters = { ..._filters.filters, ...updatedFilters }; - - runInAction(() => { - Object.keys(updatedFilters).forEach((_key) => { - set(this.filters, [viewId, "filters", _key], updatedFilters[_key as keyof IIssueFilterOptions]); - }); - }); - const appliedFilters = _filters.filters || {}; - const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); - this.rootIssueStore.workspaceIssues.fetchIssuesWithExistingPagination( - workspaceSlug, - viewId, - isEmpty(filteredFilters) ? "init-loader" : "mutation" - ); - break; - } case EIssueFilterType.DISPLAY_FILTERS: { const updatedDisplayFilters = filters as IIssueDisplayFilterOptions; _filters.displayFilters = { ..._filters.displayFilters, ...updatedDisplayFilters }; diff --git a/apps/web/core/store/label.store.ts b/apps/web/core/store/label.store.ts index 4a719f6a4..282f508e6 100644 --- a/apps/web/core/store/label.store.ts +++ b/apps/web/core/store/label.store.ts @@ -22,6 +22,8 @@ export interface ILabelStore { projectLabelsTree: IIssueLabelTree[] | undefined; workspaceLabels: IIssueLabel[] | undefined; //computed actions + getWorkspaceLabels: (workspaceSlug: string) => IIssueLabel[] | undefined; + getWorkspaceLabelIds: (workspaceSlug: string) => string[] | undefined; getProjectLabels: (projectId: string | undefined | null) => IIssueLabel[] | undefined; getProjectLabelIds: (projectId: string | undefined | null) => string[] | undefined; getLabelById: (labelId: string) => IIssueLabel | null; @@ -83,12 +85,8 @@ export class LabelStore implements ILabelStore { */ get workspaceLabels() { const currentWorkspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; - const workspaceSlug = this.rootStore.router.workspaceSlug || ""; - if (!currentWorkspaceDetails || !this.fetchedMap[workspaceSlug]) return; - return sortBy( - Object.values(this.labelMap).filter((label) => label.workspace_id === currentWorkspaceDetails.id), - "sort_order" - ); + if (!currentWorkspaceDetails) return; + return this.getWorkspaceLabels(currentWorkspaceDetails.slug); } /** @@ -112,6 +110,19 @@ export class LabelStore implements ILabelStore { return buildTree(this.projectLabels); } + getWorkspaceLabels = computedFn((workspaceSlug: string) => { + const workspaceDetails = this.rootStore.workspaceRoot.getWorkspaceBySlug(workspaceSlug); + if (!workspaceDetails || !this.fetchedMap[workspaceSlug]) return; + return sortBy( + Object.values(this.labelMap).filter((label) => label.workspace_id === workspaceDetails.id), + "sort_order" + ); + }); + + getWorkspaceLabelIds = computedFn( + (workspaceSlug: string) => this.getWorkspaceLabels(workspaceSlug)?.map((label) => label.id) ?? undefined + ); + getProjectLabels = computedFn((projectId: string | undefined | null) => { const workspaceSlug = this.rootStore.router.workspaceSlug || ""; if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return; diff --git a/apps/web/core/store/root.store.ts b/apps/web/core/store/root.store.ts index f88049e14..d1c608c3b 100644 --- a/apps/web/core/store/root.store.ts +++ b/apps/web/core/store/root.store.ts @@ -1,6 +1,7 @@ import { enableStaticRendering } from "mobx-react"; // plane imports import { FALLBACK_LANGUAGE, LANGUAGE_STORAGE_KEY } from "@plane/i18n"; +import { IWorkItemFilterStore, WorkItemFilterStore } from "@plane/shared-state"; // plane web store import { AnalyticsStore, IAnalyticsStore } from "@/plane-web/store/analytics.store"; import { CommandPaletteStore, ICommandPaletteStore } from "@/plane-web/store/command-palette.store"; @@ -64,6 +65,7 @@ export class CoreRootStore { transient: ITransientStore; stickyStore: IStickyStore; editorAssetStore: IEditorAssetStore; + workItemFilters: IWorkItemFilterStore; constructor() { this.router = new RouterStore(); @@ -94,6 +96,7 @@ export class CoreRootStore { this.stickyStore = new StickyStore(); this.editorAssetStore = new EditorAssetStore(); this.analytics = new AnalyticsStore(); + this.workItemFilters = new WorkItemFilterStore(); } resetOnSignOut() { @@ -126,5 +129,6 @@ export class CoreRootStore { this.transient = new TransientStore(); this.stickyStore = new StickyStore(); this.editorAssetStore = new EditorAssetStore(); + this.workItemFilters = new WorkItemFilterStore(); } } diff --git a/apps/web/core/store/user/settings.store.ts b/apps/web/core/store/user/settings.store.ts index 6f85ce1eb..985455cb2 100644 --- a/apps/web/core/store/user/settings.store.ts +++ b/apps/web/core/store/user/settings.store.ts @@ -1,9 +1,6 @@ import { action, makeObservable, observable, runInAction } from "mobx"; +// plane imports import { IUserSettings } from "@plane/types"; -// hooks -import { getValueFromLocalStorage, setValueIntoLocalStorage } from "@/hooks/use-local-storage"; -// local -import { persistence } from "@/local-db/storage.sqlite"; // services import { UserService } from "@/services/user.service"; @@ -12,8 +9,6 @@ type TError = { message: string; }; -const LOCAL_DB_ENABLED = "LOCAL_DB_ENABLED"; - export interface IUserSettingsStore { // observables isLoading: boolean; @@ -24,7 +19,6 @@ export interface IUserSettingsStore { isScrolled: boolean; // actions fetchCurrentUserSettings: (bustCache?: boolean) => Promise; - toggleLocalDB: (workspaceSlug: string | undefined, projectId: string | undefined) => Promise; toggleSidebar: (collapsed?: boolean) => void; toggleIsScrolled: (isScrolled?: boolean) => void; } @@ -48,7 +42,7 @@ export class UserSettingsStore implements IUserSettingsStore { invites: undefined, }, }; - canUseLocalDB: boolean = getValueFromLocalStorage(LOCAL_DB_ENABLED, true); + canUseLocalDB: boolean = false; // services userService: UserService; @@ -63,7 +57,6 @@ export class UserSettingsStore implements IUserSettingsStore { isScrolled: observable.ref, // actions fetchCurrentUserSettings: action, - toggleLocalDB: action, toggleSidebar: action, toggleIsScrolled: action, }); @@ -80,34 +73,6 @@ export class UserSettingsStore implements IUserSettingsStore { this.isScrolled = isScrolled ?? !this.isScrolled; }; - toggleLocalDB = async (workspaceSlug: string | undefined, projectId: string | undefined) => { - const currentLocalDBValue = this.canUseLocalDB; - try { - runInAction(() => { - this.canUseLocalDB = !currentLocalDBValue; - }); - - const transactionResult = setValueIntoLocalStorage(LOCAL_DB_ENABLED, !currentLocalDBValue); - - if (!transactionResult) { - throw new Error("error while toggling local DB"); - } - - if (currentLocalDBValue) { - await persistence.clearStorage(); - } else if (workspaceSlug) { - await persistence.initialize(workspaceSlug); - persistence.syncWorkspace(); - projectId && persistence.syncIssues(projectId); - } - } catch (e) { - console.warn("error while toggling local DB"); - runInAction(() => { - this.canUseLocalDB = currentLocalDBValue; - }); - } - }; - // actions /** * @description fetches user profile information diff --git a/packages/constants/src/issue/filter.ts b/packages/constants/src/issue/filter.ts index 7e9059195..ba97fde3e 100644 --- a/packages/constants/src/issue/filter.ts +++ b/packages/constants/src/issue/filter.ts @@ -1,4 +1,10 @@ -import { EIssuesStoreType, ILayoutDisplayFiltersOptions, TIssueActivityComment } from "@plane/types"; +import { + EIssuesStoreType, + IIssueFilterOptions, + ILayoutDisplayFiltersOptions, + TIssueActivityComment, + TWorkItemFilterProperty, +} from "@plane/types"; import { TIssueFilterPriorityObject, ISSUE_DISPLAY_PROPERTIES_KEYS, @@ -23,12 +29,17 @@ export enum EServerGroupByToFilterOptions { } export enum EIssueFilterType { - FILTERS = "filters", + FILTERS = "rich_filters", DISPLAY_FILTERS = "display_filters", DISPLAY_PROPERTIES = "display_properties", KANBAN_FILTERS = "kanban_filters", } +export type TSupportedFilterTypeForUpdate = + | EIssueFilterType.DISPLAY_FILTERS + | EIssueFilterType.DISPLAY_PROPERTIES + | EIssueFilterType.KANBAN_FILTERS; + export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { [key in TIssueLayout]: Record<"filters", TIssueFilterKeys[]>; } = { @@ -82,257 +93,218 @@ export const ISSUE_PRIORITY_FILTERS: TIssueFilterPriorityObject[] = [ }, ]; -export type TFiltersByLayout = { +export type TFiltersLayoutOptions = { [layoutType: string]: ILayoutDisplayFiltersOptions; }; +export type TFilterPropertiesByPageType = { + filters: TWorkItemFilterProperty[]; + layoutOptions: TFiltersLayoutOptions; +}; + export type TIssueFiltersToDisplayByPageType = { - [pageType: string]: TFiltersByLayout; + [pageType: string]: TFilterPropertiesByPageType; }; export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { profile_issues: { - list: { - filters: ["priority", "state_group", "labels", "start_date", "target_date"], - display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, - display_filters: { - group_by: ["state_detail.group", "priority", "project", "labels", null], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], - type: [null, "active", "backlog"], + filters: ["priority", "state_group", "label_id", "start_date", "target_date"], + layoutOptions: { + list: { + display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, + display_filters: { + group_by: ["state_detail.group", "priority", "project", "labels", null], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], + type: ["active", "backlog"], + }, + extra_options: { + access: true, + values: ["show_empty_groups", "sub_issue"], + }, }, - extra_options: { - access: true, - values: ["show_empty_groups", "sub_issue"], - }, - }, - kanban: { - filters: ["priority", "state_group", "labels", "start_date", "target_date"], - display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, - display_filters: { - group_by: ["state_detail.group", "priority", "project", "labels"], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], - type: [null, "active", "backlog"], - }, - extra_options: { - access: true, - values: ["show_empty_groups"], + kanban: { + display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, + display_filters: { + group_by: ["state_detail.group", "priority", "project", "labels"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], + type: ["active", "backlog"], + }, + extra_options: { + access: true, + values: ["show_empty_groups"], + }, }, }, }, archived_issues: { - list: { - filters: [ - "priority", - "state", - "cycle", - "module", - "assignees", - "created_by", - "labels", - "start_date", - "target_date", - "issue_type", - ], - display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, - display_filters: { - group_by: ["state", "cycle", "module", "priority", "labels", "assignees", "created_by", null], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], - type: [null, "active", "backlog"], - }, - extra_options: { - access: true, - values: ["show_empty_groups"], + filters: [ + "priority", + "state_group", + "state_id", + "cycle_id", + "module_id", + "assignee_id", + "created_by_id", + "label_id", + "start_date", + "target_date", + ], + layoutOptions: { + list: { + display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, + display_filters: { + group_by: ["state", "cycle", "module", "priority", "labels", "assignees", "created_by", null], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], + type: ["active", "backlog"], + }, + extra_options: { + access: true, + values: ["show_empty_groups"], + }, }, }, }, my_issues: { - spreadsheet: { - filters: [ - "priority", - "state_group", - "labels", - "assignees", - "created_by", - "subscriber", - "project", - "start_date", - "target_date", - ], - display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, - display_filters: { - order_by: [], - type: [null, "active", "backlog"], + filters: [ + "priority", + "state_group", + "label_id", + "assignee_id", + "created_by_id", + "subscriber_id", + "project_id", + "start_date", + "target_date", + ], + layoutOptions: { + spreadsheet: { + display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, + display_filters: { + order_by: [], + type: ["active", "backlog"], + }, + extra_options: { + access: true, + values: ["sub_issue"], + }, }, - extra_options: { - access: true, - values: ["sub_issue"], - }, - }, - list: { - filters: [ - "priority", - "state_group", - "labels", - "assignees", - "created_by", - "subscriber", - "project", - "start_date", - "target_date", - ], - display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, - display_filters: { - type: [null, "active", "backlog"], - }, - extra_options: { - access: false, - values: [], + list: { + display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, + display_filters: { + type: ["active", "backlog"], + }, + extra_options: { + access: false, + values: [], + }, }, }, }, issues: { - list: { - filters: [ - "priority", - "state", - "cycle", - "module", - "assignees", - "mentions", - "created_by", - "labels", - "start_date", - "target_date", - "issue_type", - ], - display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, - display_filters: { - group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by", null], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority", "target_date"], - type: [null, "active", "backlog"], + filters: [ + "priority", + "state_group", + "state_id", + "cycle_id", + "module_id", + "assignee_id", + "mention_id", + "created_by_id", + "label_id", + "start_date", + "target_date", + ], + layoutOptions: { + list: { + display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, + display_filters: { + group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by", null], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority", "target_date"], + type: ["active", "backlog"], + }, + extra_options: { + access: true, + values: ["show_empty_groups", "sub_issue"], + }, }, - extra_options: { - access: true, - values: ["show_empty_groups", "sub_issue"], + kanban: { + display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, + display_filters: { + group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by"], + sub_group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by", null], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority", "target_date"], + type: ["active", "backlog"], + }, + extra_options: { + access: true, + values: ["show_empty_groups", "sub_issue"], + }, }, - }, - kanban: { - filters: [ - "priority", - "state", - "cycle", - "module", - "assignees", - "mentions", - "created_by", - "labels", - "start_date", - "target_date", - "issue_type", - ], - display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, - display_filters: { - group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by"], - sub_group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by", null], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority", "target_date"], - type: [null, "active", "backlog"], + calendar: { + display_properties: ["key", "issue_type"], + display_filters: { + type: ["active", "backlog"], + }, + extra_options: { + access: true, + values: ["sub_issue"], + }, }, - extra_options: { - access: true, - values: ["show_empty_groups", "sub_issue"], + spreadsheet: { + display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, + display_filters: { + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], + type: ["active", "backlog"], + }, + extra_options: { + access: true, + values: ["sub_issue"], + }, }, - }, - calendar: { - filters: [ - "priority", - "state", - "cycle", - "module", - "assignees", - "mentions", - "created_by", - "labels", - "start_date", - "issue_type", - ], - display_properties: ["key", "issue_type"], - display_filters: { - type: [null, "active", "backlog"], - }, - extra_options: { - access: true, - values: ["sub_issue"], - }, - }, - spreadsheet: { - filters: [ - "priority", - "state", - "cycle", - "module", - "assignees", - "mentions", - "created_by", - "labels", - "start_date", - "target_date", - "issue_type", - ], - display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, - display_filters: { - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], - type: [null, "active", "backlog"], - }, - extra_options: { - access: true, - values: ["sub_issue"], - }, - }, - gantt_chart: { - filters: [ - "priority", - "state", - "cycle", - "module", - "assignees", - "mentions", - "created_by", - "labels", - "start_date", - "target_date", - "issue_type", - ], - display_properties: ["key", "issue_type"], - display_filters: { - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], - type: [null, "active", "backlog"], - }, - extra_options: { - access: true, - values: ["sub_issue"], + gantt_chart: { + display_properties: ["key", "issue_type"], + display_filters: { + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], + type: ["active", "backlog"], + }, + extra_options: { + access: true, + values: ["sub_issue"], + }, }, }, }, sub_work_items: { - list: { - display_properties: SUB_ISSUES_DISPLAY_PROPERTIES_KEYS, - filters: ["priority", "state", "issue_type", "assignees", "start_date", "target_date"], - display_filters: { - order_by: ["-created_at", "-updated_at", "start_date", "-priority"], - group_by: ["state", "priority", "assignees", null], - }, - extra_options: { - access: true, - values: ["sub_issue"], + filters: ["priority", "state_id", "assignee_id", "start_date", "target_date"], + layoutOptions: { + list: { + display_properties: SUB_ISSUES_DISPLAY_PROPERTIES_KEYS, + display_filters: { + order_by: ["-created_at", "-updated_at", "start_date", "-priority"], + group_by: ["state", "priority", "assignees", null], + }, + extra_options: { + access: true, + values: ["sub_issue"], + }, }, }, }, }; -export const ISSUE_STORE_TO_FILTERS_MAP: Partial> = { +export const ISSUE_STORE_TO_FILTERS_MAP: Partial> = { [EIssuesStoreType.PROJECT]: ISSUE_DISPLAY_FILTERS_BY_PAGE.issues, }; +export const SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE: (keyof IIssueFilterOptions)[] = [ + "priority", + "state", + "issue_type", + "assignees", + "start_date", + "target_date", +]; + export enum EActivityFilterType { ACTIVITY = "ACTIVITY", COMMENT = "COMMENT", diff --git a/packages/shared-state/src/store/index.ts b/packages/shared-state/src/store/index.ts index 253180f0e..186307610 100644 --- a/packages/shared-state/src/store/index.ts +++ b/packages/shared-state/src/store/index.ts @@ -1 +1,2 @@ export * from "./rich-filters"; +export * from "./work-item-filters"; diff --git a/packages/shared-state/src/store/rich-filters/config.ts b/packages/shared-state/src/store/rich-filters/config.ts index a590631d4..b93742cd0 100644 --- a/packages/shared-state/src/store/rich-filters/config.ts +++ b/packages/shared-state/src/store/rich-filters/config.ts @@ -28,8 +28,7 @@ type TOperatorOptionForDisplay = { export interface IFilterConfig

extends TFilterConfig { // computed - allSupportedOperators: TSupportedOperators[]; - allSupportedOperatorConfigs: TOperatorSpecificConfigs[keyof TOperatorSpecificConfigs][]; + allEnabledSupportedOperators: TSupportedOperators[]; firstOperator: TSupportedOperators | undefined; // computed functions getOperatorConfig: ( @@ -76,8 +75,7 @@ export class FilterConfig

["allSupportedOperators"] { - return Array.from(this.supportedOperatorConfigsMap.keys()); - } - - /** - * Returns all supported operator configs. - * @returns All supported operator configs. - */ - get allSupportedOperatorConfigs(): IFilterConfig["allSupportedOperatorConfigs"] { - return Array.from(this.supportedOperatorConfigsMap.values()); + get allEnabledSupportedOperators(): IFilterConfig["allEnabledSupportedOperators"] { + return Array.from(this.supportedOperatorConfigsMap.entries()) + .filter(([, operatorConfig]) => operatorConfig.isOperatorEnabled) + .map(([operator]) => operator); } /** @@ -107,7 +99,7 @@ export class FilterConfig

["firstOperator"] { - return this.allSupportedOperators[0]; + return this.allEnabledSupportedOperators[0]; } // ------------ computed functions ------------ @@ -168,7 +160,7 @@ export class FilterConfig

{ + /** + * Converts external work item filter expression to internal filter tree + * @param externalFilter - The external filter expression + * @returns Internal filter expression or null + */ + toInternal(externalFilter: TWorkItemFilterExpression): TFilterExpression | null { + if (!externalFilter || isEmpty(externalFilter)) return null; + + try { + return this._convertExpressionToInternal(externalFilter); + } catch (error) { + console.error("Failed to convert external filter to internal:", error); + return null; + } + } + + /** + * Recursively converts external expression data to internal filter tree + * @param expression - The external expression data + * @returns Internal filter expression + */ + private _convertExpressionToInternal( + expression: TWorkItemFilterExpressionData + ): TFilterExpression { + if (!expression || isEmpty(expression)) { + throw new Error("Invalid expression: empty or null data"); + } + + // Check if it's a simple condition (has field property) + if (this._isWorkItemFilterConditionData(expression)) { + const conditionResult = this._extractWorkItemFilterConditionData(expression); + if (!conditionResult) { + throw new Error("Failed to extract condition data"); + } + + const [property, operator, value] = conditionResult; + return createConditionNode({ + property, + operator, + value, + }); + } + + // It's a logical group - check which type + const expressionKeys = Object.keys(expression); + + if (LOGICAL_OPERATOR.AND in expression) { + const andExpression = expression as { [LOGICAL_OPERATOR.AND]: TWorkItemFilterExpressionData[] }; + const andConditions = andExpression[LOGICAL_OPERATOR.AND]; + + if (!Array.isArray(andConditions) || andConditions.length === 0) { + throw new Error("AND group must contain at least one condition"); + } + + const convertedConditions = andConditions.map((item) => this._convertExpressionToInternal(item)); + return createAndGroupNode(convertedConditions); + } + + throw new Error(`Invalid expression: unknown structure with keys [${expressionKeys.join(", ")}]`); + } + + /** + * Converts internal filter expression to external format + * @param internalFilter - The internal filter expression + * @returns External filter expression + */ + toExternal(internalFilter: TFilterExpression): TWorkItemFilterExpression { + if (!internalFilter) { + return {}; + } + + try { + return this._convertExpressionToExternal(internalFilter); + } catch (error) { + console.error("Failed to convert internal filter to external:", error); + return {}; + } + } + + /** + * Recursively converts internal expression to external format + * @param expression - The internal filter expression + * @returns External expression data + */ + private _convertExpressionToExternal( + expression: TFilterExpression + ): TWorkItemFilterExpressionData { + if (isConditionNode(expression)) { + return this._createWorkItemFilterConditionData(expression.property, expression.operator, expression.value); + } + + // It's a group node + + if (isAndGroupNode(expression)) { + return { + [LOGICAL_OPERATOR.AND]: expression.children.map((child) => this._convertExpressionToExternal(child)), + } as TWorkItemFilterExpressionData; + } + + throw new Error(`Unknown group node type for expression`); + } + + /** + * Type guard to check if data is of type TWorkItemFilterConditionData + * @param data - The data to check + * @returns True if data is TWorkItemFilterConditionData, false otherwise + */ + private _isWorkItemFilterConditionData = (data: unknown): data is TWorkItemFilterConditionData => { + if (!data || typeof data !== "object" || isEmpty(data)) return false; + + const keys = Object.keys(data); + if (keys.length === 0) return false; + + // Check if any key contains logical operators (would indicate it's a group) + const hasLogicalOperators = keys.some((key) => key === LOGICAL_OPERATOR.AND); + if (hasLogicalOperators) return false; + + // All keys must match the work item filter condition key pattern + return keys.every((key) => this._isValidWorkItemFilterConditionKey(key)); + }; + + /** + * Validates if a key is a valid work item filter condition key + * @param key - The key to validate + * @returns True if the key is valid + */ + private _isValidWorkItemFilterConditionKey = (key: string): key is TWorkItemFilterConditionKey => { + if (typeof key !== "string" || key.length === 0) return false; + + // Find the last occurrence of '__' to separate property from operator + const lastDoubleUnderscoreIndex = key.lastIndexOf("__"); + if ( + lastDoubleUnderscoreIndex === -1 || + lastDoubleUnderscoreIndex === 0 || + lastDoubleUnderscoreIndex === key.length - 2 + ) { + return false; + } + + const property = key.substring(0, lastDoubleUnderscoreIndex); + const operator = key.substring(lastDoubleUnderscoreIndex + 2); + + // Validate property is in allowed list + if (!WORK_ITEM_FILTER_PROPERTY_KEYS.includes(property as TWorkItemFilterProperty)) { + return false; + } + + // Validate operator is not empty + return operator.length > 0; + }; + + /** + * Extracts property, operator and value from work item filter condition data + * @param data - The condition data + * @returns Tuple of property, operator and value, or null if invalid + */ + private _extractWorkItemFilterConditionData = ( + data: TWorkItemFilterConditionData + ): [TWorkItemFilterProperty, TSupportedOperators, SingleOrArray] | null => { + const keys = Object.keys(data); + if (keys.length !== 1) { + console.error("Work item filter condition data must have exactly one key"); + return null; + } + + const key = keys[0]; + if (!this._isValidWorkItemFilterConditionKey(key)) { + console.error(`Invalid work item filter condition key: ${key}`); + return null; + } + + // Find the last occurrence of '__' to separate property from operator + const lastDoubleUnderscoreIndex = key.lastIndexOf("__"); + const property = key.substring(0, lastDoubleUnderscoreIndex); + const operator = key.substring(lastDoubleUnderscoreIndex + 2); + + const rawValue = data[key as TWorkItemFilterConditionKey]; + + if (typeof rawValue !== "string") { + console.error(`Filter value must be a string, got: ${typeof rawValue}`); + return null; + } + + // Parse comma-separated values + const parsedValue = this._parseFilterValue(rawValue); + + return [property as TWorkItemFilterProperty, operator as TSupportedOperators, parsedValue]; + }; + + /** + * Parses filter value from string format + * @param value - The string value to parse + * @returns Parsed value as string or array of strings + */ + private _parseFilterValue = (value: string): SingleOrArray => { + if (typeof value !== "string") return value; + + // Handle empty string + if (value === "") return value; + + // Split by comma if contains comma, otherwise return as single value + if (value.includes(",")) { + // Split and trim each value, filter out empty strings + const splitValues = value + .split(",") + .map((v) => v.trim()) + .filter((v) => v.length > 0); + + // Return single value if only one non-empty value after split + return splitValues.length === 1 ? splitValues[0] : splitValues; + } + + return value; + }; + + /** + * Creates TWorkItemFilterConditionData from property, operator and value + * @param property - The filter property key + * @param operator - The filter operator + * @param value - The filter value + * @returns The condition data object + */ + private _createWorkItemFilterConditionData = ( + property: TWorkItemFilterProperty, + operator: TSupportedOperators, + value: SingleOrArray + ): TWorkItemFilterConditionData => { + const conditionKey = `${property}__${operator}` as TWorkItemFilterConditionKey; + + // Convert value to string format + const stringValue = Array.isArray(value) ? value.join(",") : value; + + return { + [conditionKey]: stringValue, + } as TWorkItemFilterConditionData; + }; +} + +export const workItemFiltersAdapter = new WorkItemFiltersAdapter(); diff --git a/packages/shared-state/src/store/work-item-filters/filter.store.ts b/packages/shared-state/src/store/work-item-filters/filter.store.ts new file mode 100644 index 000000000..6c43b7fbf --- /dev/null +++ b/packages/shared-state/src/store/work-item-filters/filter.store.ts @@ -0,0 +1,215 @@ +import { action, makeObservable, observable } from "mobx"; +import { computedFn } from "mobx-utils"; +// plane imports +import { TExpressionOptions } from "@plane/constants"; +import { EIssuesStoreType, LOGICAL_OPERATOR, TWorkItemFilterExpression, TWorkItemFilterProperty } from "@plane/types"; +import { getOperatorForPayload } from "@plane/utils"; +// local imports +import { buildWorkItemFilterExpressionFromConditions, TWorkItemFilterCondition } from "../../utils"; +import { FilterInstance, IFilterInstance } from "../rich-filters/filter"; +import { workItemFiltersAdapter } from "./adapter"; + +type TGetOrCreateFilterParams = { + entityId: string; + entityType: EIssuesStoreType; + expressionOptions?: TExpressionOptions; + initialExpression?: TWorkItemFilterExpression; + onExpressionChange?: (expression: TWorkItemFilterExpression) => void; +}; + +type TWorkItemFilterKey = `${EIssuesStoreType}-${string}`; + +export interface IWorkItemFilterStore { + filters: Map>; // key is the entity id (project, cycle, workspace, teamspace, etc) + getFilter: ( + entityType: EIssuesStoreType, + entityId: string + ) => IFilterInstance | undefined; + getOrCreateFilter: ( + params: TGetOrCreateFilterParams + ) => IFilterInstance; + resetExpression: (entityType: EIssuesStoreType, entityId: string, expression: TWorkItemFilterExpression) => void; + updateFilterExpressionFromConditions: ( + entityType: EIssuesStoreType, + entityId: string, + conditions: TWorkItemFilterCondition[], + fallbackFn: (expression: TWorkItemFilterExpression) => Promise + ) => Promise; + updateFilterValueFromSidebar: ( + entityType: EIssuesStoreType, + entityId: string, + condition: TWorkItemFilterCondition + ) => void; + deleteFilter: (entityType: EIssuesStoreType, entityId: string) => void; +} + +export class WorkItemFilterStore implements IWorkItemFilterStore { + // observable + filters: IWorkItemFilterStore["filters"]; + + constructor() { + this.filters = new Map>(); + makeObservable(this, { + filters: observable, + getOrCreateFilter: action, + resetExpression: action, + updateFilterExpressionFromConditions: action, + deleteFilter: action, + }); + } + + // ------------ computed functions ------------ + + /** + * Returns a filter instance. + * @param entityType - The entity type. + * @param entityId - The entity id. + * @returns The filter instance. + */ + getFilter: IWorkItemFilterStore["getFilter"] = computedFn((entityType, entityId) => + this.filters.get(this._getFilterKey(entityType, entityId)) + ); + + // ------------ actions ------------ + + /** + * Gets or creates a new filter instance. + * If the instance already exists, updates its expression options to ensure they're current. + */ + getOrCreateFilter: IWorkItemFilterStore["getOrCreateFilter"] = action((params) => { + const existingFilter = this.getFilter(params.entityType, params.entityId); + if (existingFilter) { + // Update expression options on existing filter to ensure they're current + if (params.expressionOptions) { + existingFilter.updateExpressionOptions(params.expressionOptions); + } + // Update callback if provided + if (params.onExpressionChange) { + existingFilter.onExpressionChange = params.onExpressionChange; + } + return existingFilter; + } + + // create new filter instance + const newFilter = this._initializeFilterInstance(params); + this.filters.set(this._getFilterKey(params.entityType, params.entityId), newFilter); + + return newFilter; + }); + + /** + * Resets the initial expression for a filter instance. + * @param entityType - The entity type. + * @param entityId - The entity id. + * @param expression - The expression to update. + */ + resetExpression: IWorkItemFilterStore["resetExpression"] = action((entityType, entityId, expression) => { + const filter = this.getFilter(entityType, entityId); + if (filter) { + filter.resetExpression(expression); + } + }); + + /** + * Updates the filter expression from conditions. + * @param entityType - The entity type. + * @param entityId - The entity id. + * @param conditions - The conditions to update. + * @param fallbackFn - The fallback function to update the expression if the filter instance does not exist. + */ + updateFilterExpressionFromConditions: IWorkItemFilterStore["updateFilterExpressionFromConditions"] = action( + async (entityType, entityId, conditions, fallbackFn) => { + const filter = this.getFilter(entityType, entityId); + const newFilterExpression = buildWorkItemFilterExpressionFromConditions({ + conditions, + }); + if (!newFilterExpression) return; + + // Update the filter expression using the filter instance if it exists, otherwise use the fallback function + if (filter) { + filter.resetExpression(newFilterExpression, false); + } else { + await fallbackFn(newFilterExpression); + } + } + ); + + /** + * Handles sidebar filter updates by adding new conditions or updating existing ones. + * This method processes filter conditions from the sidebar UI and applies them to the + * appropriate filter instance, handling both positive and negative operators correctly. + * + * @param entityType - The entity type (e.g., project, cycle, module) + * @param entityId - The unique identifier for the entity + * @param condition - The filter condition containing property, operator, and value + */ + updateFilterValueFromSidebar: IWorkItemFilterStore["updateFilterValueFromSidebar"] = action( + (entityType, entityId, condition) => { + // Retrieve the filter instance for the specified entity + const filter = this.getFilter(entityType, entityId); + + // Early return if filter instance doesn't exist + if (!filter) { + console.warn( + `Cannot handle sidebar filters update: filter instance not found for entity type "${entityType}" with ID "${entityId}"` + ); + return; + } + + // Check for existing conditions with the same property and operator + const conditionNode = filter.findFirstConditionByPropertyAndOperator(condition.property, condition.operator); + + // No existing condition found - add new condition with AND logic + if (!conditionNode) { + const { operator, isNegation } = getOperatorForPayload(condition.operator); + + // Create the condition payload with normalized operator + const conditionPayload = { + property: condition.property, + operator, + value: condition.value, + }; + + filter.addCondition(LOGICAL_OPERATOR.AND, conditionPayload, isNegation); + return; + } + + // Update existing condition (assuming single condition per property-operator pair) + filter.updateConditionValue(conditionNode.id, condition.value); + } + ); + + /** + * Deletes a filter instance. + * @param entityType - The entity type. + * @param entityId - The entity id. + */ + deleteFilter: IWorkItemFilterStore["deleteFilter"] = action((entityType, entityId) => { + this.filters.delete(this._getFilterKey(entityType, entityId)); + }); + + // ------------ private helpers ------------ + + /** + * Returns a filter key. + * @param entityType - The entity type. + * @param entityId - The entity id.s + * @returns The filter key. + */ + _getFilterKey = (entityType: EIssuesStoreType, entityId: string): TWorkItemFilterKey => `${entityType}-${entityId}`; + + /** + * Initializes a filter instance. + * @param params - The parameters for the filter instance. + * @returns The filter instance. + */ + _initializeFilterInstance = (params: TGetOrCreateFilterParams) => + new FilterInstance({ + adapter: workItemFiltersAdapter, + initialExpression: params.initialExpression, + onExpressionChange: params.onExpressionChange, + options: { + expression: params.expressionOptions, + }, + }); +} diff --git a/packages/shared-state/src/store/work-item-filters/index.ts b/packages/shared-state/src/store/work-item-filters/index.ts new file mode 100644 index 000000000..e4acf3b74 --- /dev/null +++ b/packages/shared-state/src/store/work-item-filters/index.ts @@ -0,0 +1,2 @@ +export * from "./adapter"; +export * from "./filter.store"; diff --git a/packages/shared-state/src/utils/index.ts b/packages/shared-state/src/utils/index.ts index 42270deb7..38083592b 100644 --- a/packages/shared-state/src/utils/index.ts +++ b/packages/shared-state/src/utils/index.ts @@ -1 +1,2 @@ export * from "./rich-filter.helper"; +export * from "./work-item-filters.helper"; diff --git a/packages/shared-state/src/utils/work-item-filters.helper.ts b/packages/shared-state/src/utils/work-item-filters.helper.ts new file mode 100644 index 000000000..8763e3f75 --- /dev/null +++ b/packages/shared-state/src/utils/work-item-filters.helper.ts @@ -0,0 +1,32 @@ +// plane imports +import { + TBuildFilterExpressionParams, + TFilterConditionForBuild, + TFilterValue, + TWorkItemFilterExpression, + TWorkItemFilterProperty, +} from "@plane/types"; +// local imports +import { workItemFiltersAdapter } from "../store/work-item-filters/adapter"; +import { buildTempFilterExpressionFromConditions } from "./rich-filter.helper"; + +export type TWorkItemFilterCondition = TFilterConditionForBuild; + +/** + * Builds a work item filter expression from conditions. + * @param params.conditions - The conditions for building the filter expression. + * @returns The work item filter expression. + */ +export const buildWorkItemFilterExpressionFromConditions = ( + params: Omit< + TBuildFilterExpressionParams, + "adapter" + > +): TWorkItemFilterExpression | undefined => { + const workItemFilterExpression = buildTempFilterExpressionFromConditions({ + ...params, + adapter: workItemFiltersAdapter, + }); + if (!workItemFilterExpression) console.error("Failed to build work item filter expression from conditions"); + return workItemFilterExpression; +}; diff --git a/packages/types/src/issues.ts b/packages/types/src/issues.ts index de64c3923..44bb09e75 100644 --- a/packages/types/src/issues.ts +++ b/packages/types/src/issues.ts @@ -6,7 +6,6 @@ import { IStateLite } from "./state"; import { IUserLite } from "./users"; import { IIssueDisplayProperties, - IIssueFilterOptions, TIssueExtraOptions, TIssueGroupByOptions, TIssueGroupingFilters, @@ -219,7 +218,6 @@ export interface IIssueListRow { } export interface ILayoutDisplayFiltersOptions { - filters: (keyof IIssueFilterOptions)[]; display_properties: (keyof IIssueDisplayProperties)[]; display_filters: { group_by?: TIssueGroupByOptions[]; diff --git a/packages/types/src/rich-filters/field-types/shared.ts b/packages/types/src/rich-filters/field-types/shared.ts index 0163e9743..8626837f1 100644 --- a/packages/types/src/rich-filters/field-types/shared.ts +++ b/packages/types/src/rich-filters/field-types/shared.ts @@ -13,6 +13,7 @@ export type TNegativeOperatorConfig = { allowNegative: true; negOperatorLabel?: * - negativeOperatorConfig: Configuration for negative operators */ export type TBaseFilterFieldConfig = { + isOperatorEnabled?: boolean; operatorLabel?: string; } & TNegativeOperatorConfig; diff --git a/packages/types/src/rich-filters/operators/core.ts b/packages/types/src/rich-filters/operators/core.ts index 91c9adc28..573b1a0a3 100644 --- a/packages/types/src/rich-filters/operators/core.ts +++ b/packages/types/src/rich-filters/operators/core.ts @@ -26,13 +26,16 @@ export const CORE_COMPARISON_OPERATOR = { RANGE: "range", } as const; -// -------- TYPE EXPORTS -------- - -type TCoreEqualityOperator = (typeof CORE_EQUALITY_OPERATOR)[keyof typeof CORE_EQUALITY_OPERATOR]; -type TCoreCollectionOperator = (typeof CORE_COLLECTION_OPERATOR)[keyof typeof CORE_COLLECTION_OPERATOR]; -type TCoreComparisonOperator = (typeof CORE_COMPARISON_OPERATOR)[keyof typeof CORE_COMPARISON_OPERATOR]; +/** + * All core operators + */ +export const CORE_OPERATORS = { + ...CORE_EQUALITY_OPERATOR, + ...CORE_COLLECTION_OPERATOR, + ...CORE_COMPARISON_OPERATOR, +} as const; /** * All core operators that can be used in filter conditions */ -export type TCoreSupportedOperators = TCoreEqualityOperator | TCoreCollectionOperator | TCoreComparisonOperator; +export type TCoreSupportedOperators = (typeof CORE_OPERATORS)[keyof typeof CORE_OPERATORS]; diff --git a/packages/types/src/rich-filters/operators/extended.ts b/packages/types/src/rich-filters/operators/extended.ts index 56870326c..db54ec91e 100644 --- a/packages/types/src/rich-filters/operators/extended.ts +++ b/packages/types/src/rich-filters/operators/extended.ts @@ -18,16 +18,15 @@ export const EXTENDED_COLLECTION_OPERATOR = {} as const; */ export const EXTENDED_COMPARISON_OPERATOR = {} as const; -// -------- TYPE EXPORTS -------- - -type TExtendedEqualityOperator = (typeof EXTENDED_EQUALITY_OPERATOR)[keyof typeof EXTENDED_EQUALITY_OPERATOR]; -type TExtendedCollectionOperator = (typeof EXTENDED_COLLECTION_OPERATOR)[keyof typeof EXTENDED_COLLECTION_OPERATOR]; -type TExtendedComparisonOperator = (typeof EXTENDED_COMPARISON_OPERATOR)[keyof typeof EXTENDED_COMPARISON_OPERATOR]; - +/** + * All extended operators + */ +export const EXTENDED_OPERATORS = { + ...EXTENDED_EQUALITY_OPERATOR, + ...EXTENDED_COLLECTION_OPERATOR, + ...EXTENDED_COMPARISON_OPERATOR, +} as const; /** * All extended operators that can be used in filter conditions */ -export type TExtendedSupportedOperators = - | TExtendedEqualityOperator - | TExtendedCollectionOperator - | TExtendedComparisonOperator; +export type TExtendedSupportedOperators = (typeof EXTENDED_OPERATORS)[keyof typeof EXTENDED_OPERATORS]; diff --git a/packages/types/src/view-props.ts b/packages/types/src/view-props.ts index 2a6b9f22b..d073eafb9 100644 --- a/packages/types/src/view-props.ts +++ b/packages/types/src/view-props.ts @@ -1,4 +1,6 @@ import { TIssue } from "./issues/issue"; +import { LOGICAL_OPERATOR, TSupportedOperators } from "./rich-filters"; +import { CompleteOrEmpty } from "./utils"; export type TIssueLayouts = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt_chart"; @@ -47,7 +49,7 @@ export type TIssueOrderByOptions = | "sub_issues_count" | "-sub_issues_count"; -export type TIssueGroupingFilters = "active" | "backlog" | null; +export type TIssueGroupingFilters = "active" | "backlog"; export type TIssueExtraOptions = "show_empty_groups" | "sub_issue"; @@ -76,10 +78,47 @@ export type TIssueParams = | "per_page" | "issue_type" | "layout" - | "expand"; + | "expand" + | "filters"; export type TCalendarLayouts = "month" | "week"; +/** + * Keys for the work item filter properties + */ +export const WORK_ITEM_FILTER_PROPERTY_KEYS = [ + "state_group", + "priority", + "start_date", + "target_date", + "assignee_id", + "mention_id", + "created_by_id", + "subscriber_id", + "label_id", + "state_id", + "cycle_id", + "module_id", + "project_id", +] as const; +export type TWorkItemFilterProperty = (typeof WORK_ITEM_FILTER_PROPERTY_KEYS)[number]; + +export type TWorkItemFilterConditionKey = `${TWorkItemFilterProperty}__${TSupportedOperators}`; + +export type TWorkItemFilterConditionData = Partial<{ + [K in TWorkItemFilterConditionKey]: string; +}>; + +export type TWorkItemFilterAndGroup = { + [LOGICAL_OPERATOR.AND]: TWorkItemFilterConditionData[]; +}; + +export type TWorkItemFilterGroup = TWorkItemFilterAndGroup; + +export type TWorkItemFilterExpressionData = TWorkItemFilterConditionData | TWorkItemFilterGroup; + +export type TWorkItemFilterExpression = CompleteOrEmpty; + export interface IIssueFilterOptions { assignees?: string[] | null; mentions?: string[] | null; @@ -109,7 +148,6 @@ export interface IIssueDisplayFilterOptions { order_by?: TIssueOrderByOptions; show_empty_groups?: boolean; sub_issue?: boolean; - type?: TIssueGroupingFilters; } export interface IIssueDisplayProperties { assignee?: boolean; @@ -136,14 +174,20 @@ export type TIssueKanbanFilters = { }; export interface IIssueFilters { - filters: IIssueFilterOptions | undefined; + richFilters: TWorkItemFilterExpression; displayFilters: IIssueDisplayFilterOptions | undefined; displayProperties: IIssueDisplayProperties | undefined; kanbanFilters: TIssueKanbanFilters | undefined; } -export interface IIssueFiltersResponse { +export type TSupportedFilterForUpdate = IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters; + +export interface ISubWorkItemFilters extends Omit { filters: IIssueFilterOptions; +} + +export interface IIssueFiltersResponse { + rich_filters: TWorkItemFilterExpression; display_filters: IIssueDisplayFilterOptions; display_properties: IIssueDisplayProperties; } @@ -172,17 +216,16 @@ export interface IWorkspaceViewIssuesParams { target_date?: string | undefined; project?: string | undefined; order_by?: string | undefined; - type?: "active" | "backlog" | undefined; sub_issue?: boolean; } export interface IProjectViewProps { + rich_filters: TWorkItemFilterExpression; display_filters: IIssueDisplayFilterOptions | undefined; - filters: IIssueFilterOptions; } export interface IWorkspaceViewProps { - filters: IIssueFilterOptions; + rich_filters: TWorkItemFilterExpression; display_filters: IIssueDisplayFilterOptions | undefined; display_properties: IIssueDisplayProperties; } diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 79af8b739..42fc3ef41 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -1,5 +1,10 @@ import { TLogoProps } from "./common"; -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "./view-props"; +import { + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, + TWorkItemFilterExpression, +} from "./view-props"; export enum EViewAccess { PRIVATE, @@ -16,7 +21,7 @@ export interface IProjectView { updated_by: string; name: string; description: string; - filters: IIssueFilterOptions; + rich_filters: TWorkItemFilterExpression; display_filters: IIssueDisplayFilterOptions; display_properties: IIssueDisplayProperties; query: IIssueFilterOptions; @@ -29,6 +34,10 @@ export interface IProjectView { owned_by: string; } +export interface IPublishedProjectView extends Omit { + filters: IIssueFilterOptions; +} + export type TPublishViewSettings = { is_comments_enabled: boolean; is_reactions_enabled: boolean; diff --git a/packages/types/src/workspace-views.ts b/packages/types/src/workspace-views.ts index 00c07aec5..f31cdc2bd 100644 --- a/packages/types/src/workspace-views.ts +++ b/packages/types/src/workspace-views.ts @@ -2,7 +2,7 @@ import { IWorkspaceViewProps, IIssueDisplayFilterOptions, IIssueDisplayProperties, - IIssueFilterOptions, + TWorkItemFilterExpression, } from "./view-props"; import { EViewAccess } from "./views"; @@ -16,7 +16,7 @@ export interface IWorkspaceView { updated_by: string; name: string; description: string; - filters: IIssueFilterOptions; + rich_filters: TWorkItemFilterExpression; display_filters: IIssueDisplayFilterOptions; display_properties: IIssueDisplayProperties; query: any; @@ -32,4 +32,6 @@ export interface IWorkspaceView { }; } -export type TStaticViewTypes = "all-issues" | "assigned" | "created" | "subscribed"; +export const STATIC_VIEW_TYPES = ["all-issues", "assigned", "created", "subscribed"]; + +export type TStaticViewTypes = (typeof STATIC_VIEW_TYPES)[number]; diff --git a/packages/utils/src/filter.ts b/packages/utils/src/filter.ts index 4052d3477..ee2ca5e33 100644 --- a/packages/utils/src/filter.ts +++ b/packages/utils/src/filter.ts @@ -1,6 +1,4 @@ import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays"; -// plane imports -import { IIssueFilters } from "@plane/types"; // local imports import { getDate } from "./datetime"; @@ -63,17 +61,3 @@ export const satisfiesDateFilter = (date: Date, filter: string): boolean => { return false; }; - -/** - * @description checks if the issue filter is active - * @param {IIssueFilters} issueFilters - * @returns {boolean} - */ -export const isIssueFilterActive = (issueFilters: IIssueFilters | undefined): boolean => { - if (!issueFilters) return false; - - const issueType = issueFilters?.displayFilters?.type; - const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0 || !!issueType; - - return isFiltersApplied; -}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index d411a69d2..ddd80a886 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -28,5 +28,6 @@ export * from "./subscription"; export * from "./tab-indices"; export * from "./theme"; export * from "./url"; +export * from "./work-item-filters"; export * from "./work-item"; export * from "./workspace"; diff --git a/packages/utils/src/rich-filters/factories/configs/core.ts b/packages/utils/src/rich-filters/factories/configs/core.ts index a3450d2c0..51b16ff74 100644 --- a/packages/utils/src/rich-filters/factories/configs/core.ts +++ b/packages/utils/src/rich-filters/factories/configs/core.ts @@ -1,30 +1,7 @@ // plane imports -import { - FILTER_FIELD_TYPE, - TFilterValue, - TFilterProperty, - TFilterConfig, - TSupportedOperators, - TBaseFilterFieldConfig, -} from "@plane/types"; +import { FILTER_FIELD_TYPE, TFilterValue, TSupportedOperators, TBaseFilterFieldConfig } from "@plane/types"; // local imports -import { - createFilterFieldConfig, - DEFAULT_DATE_FILTER_TYPE_CONFIG, - DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG, - DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG, - DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG, - IFilterIconConfig, -} from "./shared"; - -/** - * Helper to create a type-safe filter config - * @param config - The filter config to create - * @returns The created filter config - */ -export const createFilterConfig =

( - config: TFilterConfig -): TFilterConfig => config; +import { createFilterFieldConfig, IFilterIconConfig } from "./shared"; // ------------ Selection filters ------------ @@ -59,12 +36,11 @@ export const getSingleSelectConfig = < TIconData extends string | number | boolean | object | undefined = undefined, >( transforms: TOptionTransforms, - config?: TSingleSelectConfig, + config: TSingleSelectConfig, iconConfig?: IFilterIconConfig ) => createFilterFieldConfig({ type: FILTER_FIELD_TYPE.SINGLE_SELECT, - ...DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG, ...config, getOptions: () => transforms.items.map((item) => ({ @@ -101,7 +77,6 @@ export const getMultiSelectConfig = < ) => createFilterFieldConfig({ type: FILTER_FIELD_TYPE.MULTI_SELECT, - ...DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG, ...config, operatorLabel: config?.operatorLabel, getOptions: () => @@ -136,10 +111,9 @@ export type TDateRangeConfig = TBaseFilterFieldConfig & { * @param config - Date-specific configuration * @returns The date picker config */ -export const getDatePickerConfig = (config?: TDateConfig) => +export const getDatePickerConfig = (config: TDateConfig) => createFilterFieldConfig({ type: FILTER_FIELD_TYPE.DATE, - ...DEFAULT_DATE_FILTER_TYPE_CONFIG, ...config, }); @@ -148,9 +122,8 @@ export const getDatePickerConfig = (config?: TDateConfig) => * @param config - Date range-specific configuration * @returns The date range picker config */ -export const getDateRangePickerConfig = (config?: TDateRangeConfig) => +export const getDateRangePickerConfig = (config: TDateRangeConfig) => createFilterFieldConfig({ type: FILTER_FIELD_TYPE.DATE_RANGE, - ...DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG, ...config, }); diff --git a/packages/utils/src/rich-filters/factories/configs/shared.ts b/packages/utils/src/rich-filters/factories/configs/shared.ts index 8647ea5d6..1c43e0a9d 100644 --- a/packages/utils/src/rich-filters/factories/configs/shared.ts +++ b/packages/utils/src/rich-filters/factories/configs/shared.ts @@ -10,8 +10,59 @@ import { TMultiSelectFilterFieldConfig, TSingleSelectFilterFieldConfig, TSupportedFilterFieldConfigs, + TSupportedOperators, } from "@plane/types"; +/** + * Helper to create a type-safe filter config + * @param config - The filter config to create + * @returns The created filter config + */ +export const createFilterConfig =

( + config: TFilterConfig +): TFilterConfig => config; + +/** + * Base parameters for filter type config factory functions. + * - operator: The operator to use for the filter. + */ +export type TCreateFilterConfigParams = Omit & { + isEnabled: boolean; + allowedOperators: Set; +}; + +/** + * Icon configuration for filters and their options. + * - filterIcon: Optional icon for the filter + * - getOptionIcon: Function to get icon for specific option values + */ +export interface IFilterIconConfig { + filterIcon?: React.FC>; + getOptionIcon?: (value: T) => React.ReactNode; +} + +/** + * Date filter config params + */ +export type TCreateDateFilterParams = TCreateFilterConfigParams & IFilterIconConfig; + +/** + * Helper to create an operator entry for the supported operators map. + * This ensures consistency between the operator key and the operator passed to the config function. + * @param operator - The operator to use as both key and parameter + * @param createParams - The base filter configuration parameters + * @param configFn - Function that creates the operator config using base configuration + * @returns A tuple of operator and its config + */ +export const createOperatorConfigEntry = ( + operator: TSupportedOperators, + createParams: P, + configFn: (updatedParams: P) => T +): [TSupportedOperators, T] => [ + operator, + configFn({ isOperatorEnabled: createParams.allowedOperators.has(operator), ...createParams }), +]; + /** * Factory function signature for creating filter configurations. */ @@ -33,44 +84,3 @@ export const createFilterFieldConfig = : never ): TSupportedFilterFieldConfigs => config as TSupportedFilterFieldConfigs; - -/** - * Base parameters for filter type config factory functions. - * - operator: The operator to use for the filter. - */ -export type TCreateFilterConfigParams = TBaseFilterFieldConfig & { - isEnabled: boolean; -}; - -/** - * Icon configuration for filters and their options. - * - filterIcon: Optional icon for the filter - * - getOptionIcon: Function to get icon for specific option values - */ -export interface IFilterIconConfig { - filterIcon?: React.FC>; - getOptionIcon?: (value: T) => React.ReactNode; -} - -/** - * Date filter config params - */ -export type TCreateDateFilterParams = TCreateFilterConfigParams & IFilterIconConfig; - -// ------------ Default filter type configs ------------ - -export const DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG = { - allowNegative: false, -}; - -export const DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG = { - allowNegative: false, -}; - -export const DEFAULT_DATE_FILTER_TYPE_CONFIG = { - allowNegative: false, -}; - -export const DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG = { - allowNegative: false, -}; diff --git a/packages/utils/src/work-item-filters/configs/filters/cycle.ts b/packages/utils/src/work-item-filters/configs/filters/cycle.ts new file mode 100644 index 000000000..08b8aff6b --- /dev/null +++ b/packages/utils/src/work-item-filters/configs/filters/cycle.ts @@ -0,0 +1,70 @@ +// plane imports +import { + EQUALITY_OPERATOR, + ICycle, + TCycleGroups, + TFilterProperty, + COLLECTION_OPERATOR, + TSupportedOperators, +} from "@plane/types"; +// local imports +import { + createFilterConfig, + TCreateFilterConfigParams, + IFilterIconConfig, + TCreateFilterConfig, + getMultiSelectConfig, + createOperatorConfigEntry, +} from "../../../rich-filters"; + +/** + * Cycle filter specific params + */ +export type TCreateCycleFilterParams = TCreateFilterConfigParams & + IFilterIconConfig & { + cycles: ICycle[]; + }; + +/** + * Helper to get the cycle multi select config + * @param params - The filter params + * @returns The cycle multi select config + */ +export const getCycleMultiSelectConfig = (params: TCreateCycleFilterParams, singleValueOperator: TSupportedOperators) => + getMultiSelectConfig( + { + items: params.cycles, + getId: (cycle) => cycle.id, + getLabel: (cycle) => cycle.name, + getValue: (cycle) => cycle.id, + getIconData: (cycle) => cycle.status || "draft", + }, + { + singleValueOperator, + ...params, + }, + { + ...params, + } + ); + +/** + * Get the cycle filter config + * @template K - The filter key + * @param key - The filter key to use + * @returns A function that takes parameters and returns the cycle filter config + */ +export const getCycleFilterConfig = +

(key: P): TCreateFilterConfig => + (params: TCreateCycleFilterParams) => + createFilterConfig({ + id: key, + label: "Cycle", + icon: params.filterIcon, + isEnabled: params.isEnabled, + supportedOperatorConfigsMap: new Map([ + createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) => + getCycleMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT) + ), + ]), + }); diff --git a/packages/utils/src/work-item-filters/configs/filters/date.ts b/packages/utils/src/work-item-filters/configs/filters/date.ts new file mode 100644 index 000000000..1de8f9728 --- /dev/null +++ b/packages/utils/src/work-item-filters/configs/filters/date.ts @@ -0,0 +1,43 @@ +// plane imports +import { TFilterProperty } from "@plane/types"; +// local imports +import { createFilterConfig, TCreateFilterConfig, TCreateDateFilterParams } from "../../../rich-filters"; +import { getSupportedDateOperators } from "./shared"; + +// ------------ Date filters ------------ + +/** + * Get the start date filter config + * @template K - The filter key + * @param key - The filter key to use + * @returns A function that takes parameters and returns the start date filter config + */ +export const getStartDateFilterConfig = +

(key: P): TCreateFilterConfig => + (params: TCreateDateFilterParams) => + createFilterConfig({ + id: key, + label: "Start date", + icon: params.filterIcon, + isEnabled: params.isEnabled, + allowMultipleFilters: true, + supportedOperatorConfigsMap: getSupportedDateOperators(params), + }); + +/** + * Get the target date filter config + * @template K - The filter key + * @param key - The filter key to use + * @returns A function that takes parameters and returns the target date filter config + */ +export const getTargetDateFilterConfig = +

(key: P): TCreateFilterConfig => + (params: TCreateDateFilterParams) => + createFilterConfig({ + id: key, + label: "Target date", + icon: params.filterIcon, + isEnabled: params.isEnabled, + allowMultipleFilters: true, + supportedOperatorConfigsMap: getSupportedDateOperators(params), + }); diff --git a/packages/utils/src/work-item-filters/configs/filters/index.ts b/packages/utils/src/work-item-filters/configs/filters/index.ts new file mode 100644 index 000000000..7498018b1 --- /dev/null +++ b/packages/utils/src/work-item-filters/configs/filters/index.ts @@ -0,0 +1,8 @@ +export * from "./cycle"; +export * from "./date"; +export * from "./label"; +export * from "./module"; +export * from "./priority"; +export * from "./project"; +export * from "./state"; +export * from "./user"; diff --git a/packages/utils/src/work-item-filters/configs/filters/label.ts b/packages/utils/src/work-item-filters/configs/filters/label.ts new file mode 100644 index 000000000..41fa84bf3 --- /dev/null +++ b/packages/utils/src/work-item-filters/configs/filters/label.ts @@ -0,0 +1,69 @@ +// plane imports +import { + EQUALITY_OPERATOR, + IIssueLabel, + TFilterProperty, + COLLECTION_OPERATOR, + TSupportedOperators, +} from "@plane/types"; +// local imports +import { + createFilterConfig, + TCreateFilterConfigParams, + IFilterIconConfig, + TCreateFilterConfig, + getMultiSelectConfig, + createOperatorConfigEntry, +} from "../../../rich-filters"; + +/** + * Label filter specific params + */ +export type TCreateLabelFilterParams = TCreateFilterConfigParams & + IFilterIconConfig & { + labels: IIssueLabel[]; + }; + +/** + * Helper to get the label multi select config + * @param params - The filter params + * @returns The label multi select config + */ +export const getLabelMultiSelectConfig = (params: TCreateLabelFilterParams, singleValueOperator: TSupportedOperators) => + getMultiSelectConfig( + { + items: params.labels, + getId: (label) => label.id, + getLabel: (label) => label.name, + getValue: (label) => label.id, + getIconData: (label) => label.color, + }, + { + singleValueOperator, + ...params, + }, + { + getOptionIcon: params.getOptionIcon, + } + ); + +/** + * Get the label filter config + * @template K - The filter key + * @param key - The filter key to use + * @returns A function that takes parameters and returns the label filter config + */ +export const getLabelFilterConfig = +

(key: P): TCreateFilterConfig => + (params: TCreateLabelFilterParams) => + createFilterConfig({ + id: key, + label: "Label", + icon: params.filterIcon, + isEnabled: params.isEnabled, + supportedOperatorConfigsMap: new Map([ + createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) => + getLabelMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT) + ), + ]), + }); diff --git a/packages/utils/src/work-item-filters/configs/filters/module.ts b/packages/utils/src/work-item-filters/configs/filters/module.ts new file mode 100644 index 000000000..0c595eb25 --- /dev/null +++ b/packages/utils/src/work-item-filters/configs/filters/module.ts @@ -0,0 +1,63 @@ +// plane imports +import { EQUALITY_OPERATOR, IModule, TFilterProperty, COLLECTION_OPERATOR } from "@plane/types"; +// local imports +import { + createFilterConfig, + TCreateFilterConfigParams, + IFilterIconConfig, + TCreateFilterConfig, + getMultiSelectConfig, + createOperatorConfigEntry, +} from "../../../rich-filters"; + +/** + * Module filter specific params + */ +export type TCreateModuleFilterParams = TCreateFilterConfigParams & + IFilterIconConfig & { + modules: IModule[]; + }; + +/** + * Helper to get the module multi select config + * @param params - The filter params + * @returns The module multi select config + */ +export const getModuleMultiSelectConfig = (params: TCreateModuleFilterParams) => + getMultiSelectConfig( + { + items: params.modules, + getId: (module) => module.id, + getLabel: (module) => module.name, + getValue: (module) => module.id, + getIconData: () => undefined, + }, + { + singleValueOperator: EQUALITY_OPERATOR.EXACT, + ...params, + }, + { + ...params, + } + ); + +/** + * Get the module filter config + * @template K - The filter key + * @param key - The filter key to use + * @returns A function that takes parameters and returns the module filter config + */ +export const getModuleFilterConfig = +

(key: P): TCreateFilterConfig => + (params: TCreateModuleFilterParams) => + createFilterConfig({ + id: key, + label: "Module", + icon: params.filterIcon, + isEnabled: params.isEnabled, + supportedOperatorConfigsMap: new Map([ + createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) => + getModuleMultiSelectConfig(updatedParams) + ), + ]), + }); diff --git a/packages/utils/src/work-item-filters/configs/filters/priority.ts b/packages/utils/src/work-item-filters/configs/filters/priority.ts new file mode 100644 index 000000000..b04a3b786 --- /dev/null +++ b/packages/utils/src/work-item-filters/configs/filters/priority.ts @@ -0,0 +1,66 @@ +// plane imports +import { ISSUE_PRIORITIES, TIssuePriorities } from "@plane/constants"; +import { EQUALITY_OPERATOR, TFilterProperty, COLLECTION_OPERATOR, TSupportedOperators } from "@plane/types"; +// local imports +import { + createFilterConfig, + TCreateFilterConfigParams, + IFilterIconConfig, + TCreateFilterConfig, + getMultiSelectConfig, + createOperatorConfigEntry, +} from "../../../rich-filters"; + +// ------------ Priority filter ------------ + +/** + * Priority filter specific params + */ +export type TCreatePriorityFilterParams = TCreateFilterConfigParams & IFilterIconConfig; + +/** + * Helper to get the priority multi select config + * @param params - The filter params + * @returns The priority multi select config + */ +export const getPriorityMultiSelectConfig = ( + params: TCreatePriorityFilterParams, + singleValueOperator: TSupportedOperators +) => + getMultiSelectConfig<{ key: TIssuePriorities; title: string }, TIssuePriorities, TIssuePriorities>( + { + items: ISSUE_PRIORITIES, + getId: (priority) => priority.key, + getLabel: (priority) => priority.title, + getValue: (priority) => priority.key, + getIconData: (priority) => priority.key, + }, + { + singleValueOperator, + ...params, + }, + { + getOptionIcon: params.getOptionIcon, + } + ); + +/** + * Get the priority filter config + * @template K - The filter key + * @param key - The filter key to use + * @returns A function that takes parameters and returns the priority filter config + */ +export const getPriorityFilterConfig = +

(key: P): TCreateFilterConfig => + (params: TCreatePriorityFilterParams) => + createFilterConfig({ + id: key, + label: "Priority", + icon: params.filterIcon, + isEnabled: params.isEnabled, + supportedOperatorConfigsMap: new Map([ + createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) => + getPriorityMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT) + ), + ]), + }); diff --git a/packages/utils/src/work-item-filters/configs/filters/project.ts b/packages/utils/src/work-item-filters/configs/filters/project.ts new file mode 100644 index 000000000..b5c123ed4 --- /dev/null +++ b/packages/utils/src/work-item-filters/configs/filters/project.ts @@ -0,0 +1,28 @@ +// plane imports +import { EQUALITY_OPERATOR, TFilterProperty, COLLECTION_OPERATOR } from "@plane/types"; +// local imports +import { createFilterConfig, createOperatorConfigEntry, TCreateFilterConfig } from "../../../rich-filters"; +import { getProjectMultiSelectConfig, TCreateProjectFilterParams } from "./shared"; + +// ------------ Project filter ------------ + +/** + * Get the project filter config + * @template K - The filter key + * @param key - The filter key to use + * @returns A function that takes parameters and returns the project filter config + */ +export const getProjectFilterConfig = +

(key: P): TCreateFilterConfig => + (params: TCreateProjectFilterParams) => + createFilterConfig({ + id: key, + label: "Projects", + icon: params.filterIcon, + isEnabled: params.isEnabled, + supportedOperatorConfigsMap: new Map([ + createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) => + getProjectMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT) + ), + ]), + }); diff --git a/packages/utils/src/work-item-filters/configs/filters/shared.ts b/packages/utils/src/work-item-filters/configs/filters/shared.ts new file mode 100644 index 000000000..af501d62e --- /dev/null +++ b/packages/utils/src/work-item-filters/configs/filters/shared.ts @@ -0,0 +1,64 @@ +// plane imports +import { + COMPARISON_OPERATOR, + EQUALITY_OPERATOR, + IProject, + TOperatorConfigMap, + TSupportedOperators, +} from "@plane/types"; +// local imports +import { + createOperatorConfigEntry, + getDatePickerConfig, + getDateRangePickerConfig, + getMultiSelectConfig, + IFilterIconConfig, + TCreateDateFilterParams, + TCreateFilterConfigParams, +} from "../../../rich-filters"; + +// ------------ Date filter ------------ + +export const getSupportedDateOperators = (params: TCreateDateFilterParams): TOperatorConfigMap => + new Map([ + createOperatorConfigEntry(EQUALITY_OPERATOR.EXACT, params, (updatedParams) => getDatePickerConfig(updatedParams)), + createOperatorConfigEntry(COMPARISON_OPERATOR.RANGE, params, (updatedParams) => + getDateRangePickerConfig(updatedParams) + ), + ]); + +// ------------ Project filter ------------ + +/** + * Project filter specific params + */ +export type TCreateProjectFilterParams = TCreateFilterConfigParams & + IFilterIconConfig & { + projects: IProject[]; + }; + +/** + * Helper to get the project multi select config + * @param params - The filter params + * @returns The member multi select config + */ +export const getProjectMultiSelectConfig = ( + params: TCreateProjectFilterParams, + singleValueOperator: TSupportedOperators +) => + getMultiSelectConfig( + { + items: params.projects, + getId: (project) => project.id, + getLabel: (project) => project.name, + getValue: (project) => project.id, + getIconData: (project) => project, + }, + { + singleValueOperator, + ...params, + }, + { + ...params, + } + ); diff --git a/packages/utils/src/work-item-filters/configs/filters/state.ts b/packages/utils/src/work-item-filters/configs/filters/state.ts new file mode 100644 index 000000000..2281171e1 --- /dev/null +++ b/packages/utils/src/work-item-filters/configs/filters/state.ts @@ -0,0 +1,127 @@ +// plane imports +import { STATE_GROUPS } from "@plane/constants"; +import { + COLLECTION_OPERATOR, + EQUALITY_OPERATOR, + IState, + TFilterProperty, + TStateGroups, + TSupportedOperators, +} from "@plane/types"; +// local imports +import { + createFilterConfig, + getMultiSelectConfig, + IFilterIconConfig, + TCreateFilterConfig, + TCreateFilterConfigParams, + createOperatorConfigEntry, +} from "../../../rich-filters"; + +// ------------ State group filter ------------ + +/** + * State group filter specific params + */ +export type TCreateStateGroupFilterParams = TCreateFilterConfigParams & IFilterIconConfig; + +/** + * Helper to get the state group multi select config + * @param params - The filter params + * @returns The state group multi select config + */ +export const getStateGroupMultiSelectConfig = ( + params: TCreateStateGroupFilterParams, + singleValueOperator: TSupportedOperators +) => + getMultiSelectConfig<{ key: TStateGroups; label: string }, TStateGroups, TStateGroups>( + { + items: Object.values(STATE_GROUPS), + getId: (state) => state.key, + getLabel: (state) => state.label, + getValue: (state) => state.key, + getIconData: (state) => state.key, + }, + { + singleValueOperator, + ...params, + }, + { + ...params, + } + ); + +/** + * Get the state group filter config + * @template K - The filter key + * @param key - The filter key to use + * @returns A function that takes parameters and returns the state group filter config + */ +export const getStateGroupFilterConfig = +

(key: P): TCreateFilterConfig => + (params: TCreateStateGroupFilterParams) => + createFilterConfig({ + id: key, + label: "State Group", + icon: params.filterIcon, + isEnabled: params.isEnabled, + supportedOperatorConfigsMap: new Map([ + createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) => + getStateGroupMultiSelectConfig(updatedParams, EQUALITY_OPERATOR.EXACT) + ), + ]), + }); + +// ------------ State filter ------------ + +/** + * State filter specific params + */ +export type TCreateStateFilterParams = TCreateFilterConfigParams & + IFilterIconConfig & { + states: IState[]; + }; + +/** + * Helper to get the state multi select config + * @param params - The filter params + * @returns The state multi select config + */ +export const getStateMultiSelectConfig = (params: TCreateStateFilterParams) => + getMultiSelectConfig( + { + items: params.states, + getId: (state) => state.id, + getLabel: (state) => state.name, + getValue: (state) => state.id, + getIconData: (state) => state, + }, + { + singleValueOperator: EQUALITY_OPERATOR.EXACT, + ...params, + }, + { + ...params, + } + ); + +/** + * Get the state filter config + * @template K - The filter key + * @param key - The filter key to use + * @returns A function that takes parameters and returns the state filter config + */ +export const getStateFilterConfig = +

(key: P): TCreateFilterConfig => + (params: TCreateStateFilterParams) => + createFilterConfig({ + id: key, + label: "State", + icon: params.filterIcon, + isEnabled: params.isEnabled, + supportedOperatorConfigsMap: new Map([ + createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) => + getStateMultiSelectConfig(updatedParams) + ), + ]), + }); diff --git a/packages/utils/src/work-item-filters/configs/filters/user.ts b/packages/utils/src/work-item-filters/configs/filters/user.ts new file mode 100644 index 000000000..cae90b871 --- /dev/null +++ b/packages/utils/src/work-item-filters/configs/filters/user.ts @@ -0,0 +1,156 @@ +// plane imports +import { EQUALITY_OPERATOR, IUserLite, TFilterProperty, COLLECTION_OPERATOR } from "@plane/types"; +// local imports +import { + createFilterConfig, + TCreateFilterConfigParams, + IFilterIconConfig, + TCreateFilterConfig, + getMultiSelectConfig, + createOperatorConfigEntry, +} from "../../../rich-filters"; + +// ------------ Base User Filter Types ------------ + +/** + * User filter specific params + */ +export type TCreateUserFilterParams = TCreateFilterConfigParams & + IFilterIconConfig & { + members: IUserLite[]; + }; + +/** + * Helper to get the member multi select config + * @param params - The filter params + * @returns The member multi select config + */ +export const getMemberMultiSelectConfig = (params: TCreateUserFilterParams) => + getMultiSelectConfig( + { + items: params.members, + getId: (member) => member.id, + getLabel: (member) => member.display_name, + getValue: (member) => member.id, + getIconData: (member) => member, + }, + { + singleValueOperator: EQUALITY_OPERATOR.EXACT, + ...params, + }, + { + ...params, + } + ); + +// ------------ Assignee filter ------------ + +/** + * Assignee filter specific params + */ +export type TCreateAssigneeFilterParams = TCreateUserFilterParams; + +/** + * Get the assignee filter config + * @template K - The filter key + * @param key - The filter key to use + * @returns A function that takes parameters and returns the assignee filter config + */ +export const getAssigneeFilterConfig = +

(key: P): TCreateFilterConfig => + (params: TCreateAssigneeFilterParams) => + createFilterConfig({ + id: key, + label: "Assignees", + icon: params.filterIcon, + isEnabled: params.isEnabled, + supportedOperatorConfigsMap: new Map([ + createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) => + getMemberMultiSelectConfig(updatedParams) + ), + ]), + }); + +// ------------ Mention filter ------------ + +/** + * Mention filter specific params + */ +export type TCreateMentionFilterParams = TCreateUserFilterParams; + +/** + * Get the mention filter config + * @template K - The filter key + * @param key - The filter key to use + * @returns A function that takes parameters and returns the mention filter config + */ +export const getMentionFilterConfig = +

(key: P): TCreateFilterConfig => + (params: TCreateMentionFilterParams) => + createFilterConfig({ + id: key, + label: "Mentions", + icon: params.filterIcon, + isEnabled: params.isEnabled, + supportedOperatorConfigsMap: new Map([ + createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) => + getMemberMultiSelectConfig(updatedParams) + ), + ]), + }); + +// ------------ Created by filter ------------ + +/** + * Created by filter specific params + */ +export type TCreateCreatedByFilterParams = TCreateUserFilterParams; + +/** + * Get the created by filter config + * @template K - The filter key + * @param key - The filter key to use + * @returns A function that takes parameters and returns the created by filter config + */ +export const getCreatedByFilterConfig = +

(key: P): TCreateFilterConfig => + (params: TCreateCreatedByFilterParams) => + createFilterConfig({ + id: key, + label: "Created by", + icon: params.filterIcon, + isEnabled: params.isEnabled, + supportedOperatorConfigsMap: new Map([ + createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) => + getMemberMultiSelectConfig(updatedParams) + ), + ]), + }); + +// ------------ Subscriber filter ------------ + +/** + * Subscriber filter specific params + */ +export type TCreateSubscriberFilterParams = TCreateUserFilterParams; + +/** + * Get the subscriber filter config + * @template K - The filter key + * @param key - The filter key to use + * @returns A function that takes parameters and returns the subscriber filter config + */ +export const getSubscriberFilterConfig = +

(key: P): TCreateFilterConfig => + (params: TCreateSubscriberFilterParams) => + createFilterConfig({ + id: key, + label: "Subscriber", + icon: params.filterIcon, + isEnabled: params.isEnabled, + supportedOperatorConfigsMap: new Map([ + createOperatorConfigEntry(COLLECTION_OPERATOR.IN, params, (updatedParams) => + getMemberMultiSelectConfig(updatedParams) + ), + ]), + }); diff --git a/packages/utils/src/work-item-filters/configs/index.ts b/packages/utils/src/work-item-filters/configs/index.ts new file mode 100644 index 000000000..302e3a1a6 --- /dev/null +++ b/packages/utils/src/work-item-filters/configs/index.ts @@ -0,0 +1 @@ +export * from "./filters"; diff --git a/packages/utils/src/work-item-filters/index.ts b/packages/utils/src/work-item-filters/index.ts new file mode 100644 index 000000000..3158367fc --- /dev/null +++ b/packages/utils/src/work-item-filters/index.ts @@ -0,0 +1 @@ +export * from "./configs"; diff --git a/packages/utils/src/work-item/base.ts b/packages/utils/src/work-item/base.ts index b16289c93..61fd44c5f 100644 --- a/packages/utils/src/work-item/base.ts +++ b/packages/utils/src/work-item/base.ts @@ -4,15 +4,16 @@ import { v4 as uuidv4 } from "uuid"; // plane imports import { ISSUE_DISPLAY_FILTERS_BY_PAGE, - STATE_GROUPS, - TIssuePriorities, ISSUE_PRIORITY_FILTERS, + STATE_GROUPS, TIssueFilterPriorityObject, + TIssuePriorities, } from "@plane/constants"; import { + EIssueLayoutTypes, + IGanttBlock, IIssueDisplayFilterOptions, IIssueDisplayProperties, - IGanttBlock, TGroupedIssues, TIssue, TIssueGroupByOptions, @@ -21,7 +22,6 @@ import { TStateGroups, TSubGroupedIssues, TUnGroupedIssues, - EIssueLayoutTypes, } from "@plane/types"; // local imports import { orderArrayBy } from "../array"; @@ -111,25 +111,20 @@ export const handleIssueQueryParamsByLayout = ( | "team_issues" | "team_project_work_items" ): TIssueParams[] | null => { - const queryParams: TIssueParams[] = []; + const queryParams: TIssueParams[] = ["filters"]; if (!layout) return null; - const layoutOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE[viewType][layout]; - - // add filters query params - layoutOptions.filters.forEach((option) => { - queryParams.push(option); - }); + const currentViewLayoutOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE[viewType].layoutOptions[layout]; // add display filters query params - Object.keys(layoutOptions.display_filters).forEach((option) => { + Object.keys(currentViewLayoutOptions.display_filters).forEach((option) => { queryParams.push(option as TIssueParams); }); // add extra options query params - if (layoutOptions.extra_options.access) { - layoutOptions.extra_options.values.forEach((option) => { + if (currentViewLayoutOptions.extra_options.access) { + currentViewLayoutOptions.extra_options.values.forEach((option) => { queryParams.push(option); }); } @@ -286,7 +281,6 @@ export const getComputedDisplayFilters = ( order_by: filters?.order_by || "sort_order", group_by: filters?.group_by || null, sub_group_by: filters?.sub_group_by || null, - type: filters?.type || null, sub_issue: filters?.sub_issue || false, show_empty_groups: filters?.show_empty_groups || false, };