From 5b776392bd60c01f224b73e3254b25103460ec27 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Sat, 17 May 2025 17:11:26 +0530 Subject: [PATCH] chore: revamped the analytics for cycle and module in peek view. (#7075) * chore: added cycles and modules in analytics peek view * chore: added cycles and modules analytics * chore: added project filter for work items * chore: added a peekview flag and based on that table columns * chore: added peek view * chore: added check for display name * chore: cleaned up some code * chore: fixed export csv data * chore: added distinct work items * chore: assignee in peek view * updated csv fields * chore: updated workitems peek with assignee * fix: removed type assersions for workspaceslug * chore: added day wise filter in cycles and modules * chore: added extra validations --------- Co-authored-by: JayashTripathy --- apiserver/plane/app/urls/analytic.py | 6 - apiserver/plane/app/views/__init__.py | 1 - apiserver/plane/app/views/analytic/advance.py | 409 ++++++++++++------ apiserver/plane/utils/date_utils.py | 4 +- packages/types/src/analytics-v2.d.ts | 51 ++- .../[projectId]/cycles/(detail)/header.tsx | 5 +- .../cycles/(detail)/mobile-header.tsx | 5 +- .../[projectId]/modules/(detail)/header.tsx | 5 +- .../modules/(detail)/mobile-header.tsx | 4 +- .../analytics-v2/insight-table/root.tsx | 4 +- .../overview/project-insights.tsx | 10 +- .../analytics-v2/total-insights.tsx | 10 +- .../work-items/created-vs-resolved.tsx | 10 +- .../analytics-v2/work-items/modal/content.tsx | 50 ++- .../analytics-v2/work-items/modal/header.tsx | 13 +- .../analytics-v2/work-items/modal/index.tsx | 15 +- .../work-items/priority-chart.tsx | 31 +- .../work-items/workitems-insight-table.tsx | 97 +++-- web/core/components/analytics/old-page.tsx | 107 +++++ web/core/store/analytics-v2.store.ts | 39 +- web/core/store/root.store.ts | 2 +- 21 files changed, 642 insertions(+), 236 deletions(-) create mode 100644 web/core/components/analytics/old-page.tsx diff --git a/apiserver/plane/app/urls/analytic.py b/apiserver/plane/app/urls/analytic.py index c6f024f75..0eebd3108 100644 --- a/apiserver/plane/app/urls/analytic.py +++ b/apiserver/plane/app/urls/analytic.py @@ -11,7 +11,6 @@ from plane.app.views import ( AdvanceAnalyticsChartEndpoint, DefaultAnalyticsEndpoint, ProjectStatsEndpoint, - AdvanceAnalyticsExportEndpoint, ) @@ -68,9 +67,4 @@ urlpatterns = [ AdvanceAnalyticsChartEndpoint.as_view(), name="advance-analytics-chart", ), - path( - "workspaces//advance-analytics-export/", - AdvanceAnalyticsExportEndpoint.as_view(), - name="advance-analytics-export", - ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index a3c72f370..2034c5548 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -203,7 +203,6 @@ from .analytic.advance import ( AdvanceAnalyticsEndpoint, AdvanceAnalyticsStatsEndpoint, AdvanceAnalyticsChartEndpoint, - AdvanceAnalyticsExportEndpoint, ) from .notification.base import ( diff --git a/apiserver/plane/app/views/analytic/advance.py b/apiserver/plane/app/views/analytic/advance.py index 127d22acb..b6c5f1e0b 100644 --- a/apiserver/plane/app/views/analytic/advance.py +++ b/apiserver/plane/app/views/analytic/advance.py @@ -5,7 +5,7 @@ from django.db.models import QuerySet, Q, Count from django.http import HttpRequest from django.db.models.functions import TruncMonth from django.utils import timezone - +from datetime import timedelta from plane.app.views.base import BaseAPIView from plane.app.permissions import ROLE, allow_permission from plane.db.models import ( @@ -16,19 +16,18 @@ from plane.db.models import ( Module, IssueView, ProjectPage, - Workspace + Workspace, + CycleIssue, + ModuleIssue, ) - +from django.db import models +from django.db.models import F, Case, When, Value +from django.db.models.functions import Concat from plane.utils.build_chart import build_analytics_chart -from plane.bgtasks.analytic_plot_export import export_analytics_to_csv_email from plane.utils.date_utils import ( get_analytics_filters, ) -from plane.utils.build_chart import build_analytics_chart -from plane.bgtasks.analytic_plot_export import export_analytics_to_csv_email -from plane.utils.date_utils import get_analytics_filters - class AdvanceAnalyticsBaseView(BaseAPIView): def initialize_workspace(self, slug: str, type: str) -> None: @@ -73,7 +72,7 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView): return { "count": get_filtered_count(), - "filter_count": get_previous_count(), + # "filter_count": get_previous_count(), } def get_overview_data(self) -> Dict[str, Dict[str, int]]: @@ -120,9 +119,25 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView): ), } - - def get_work_items_stats(self) -> Dict[str, Dict[str, int]]: - base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + def get_work_items_stats( + self, cycle_id=None, module_id=None + ) -> Dict[str, Dict[str, int]]: + """ + Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided. + """ + base_queryset = None + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter( + **self.filters["base_filters"], cycle_id=cycle_id + ).values_list("issue_id", flat=True) + base_queryset = Issue.issue_objects.filter(id__in=cycle_issues) + elif module_id is not None: + module_issues = ModuleIssue.objects.filter( + **self.filters["base_filters"], module_id=module_id + ).values_list("issue_id", flat=True) + base_queryset = Issue.issue_objects.filter(id__in=module_issues) + else: + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) return { "total_work_items": self.get_filtered_counts(base_queryset), @@ -150,13 +165,14 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView): self.get_overview_data(), status=status.HTTP_200_OK, ) - elif tab == "work-items": + # Optionally accept cycle_id or module_id as query params + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) return Response( - self.get_work_items_stats(), + self.get_work_items_stats(cycle_id=cycle_id, module_id=module_id), status=status.HTTP_200_OK, ) - return Response({"message": "Invalid tab"}, status=status.HTTP_400_BAD_REQUEST) @@ -184,14 +200,100 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView): .order_by("project_id") ) + def get_work_items_stats( + self, cycle_id=None, module_id=None, peek_view=False + ) -> Dict[str, Dict[str, int]]: + base_queryset = None + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter( + **self.filters["base_filters"], cycle_id=cycle_id + ).values_list("issue_id", flat=True) + base_queryset = Issue.issue_objects.filter(id__in=cycle_issues) + elif module_id is not None: + module_issues = ModuleIssue.objects.filter( + **self.filters["base_filters"], module_id=module_id + ).values_list("issue_id", flat=True) + base_queryset = Issue.issue_objects.filter(id__in=module_issues) + elif peek_view: + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + else: + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + return ( + base_queryset.values("project_id", "project__name") + .annotate( + cancelled_work_items=Count( + "id", filter=Q(state__group="cancelled") + ), + completed_work_items=Count( + "id", filter=Q(state__group="completed") + ), + backlog_work_items=Count("id", filter=Q(state__group="backlog")), + un_started_work_items=Count( + "id", filter=Q(state__group="unstarted") + ), + started_work_items=Count("id", filter=Q(state__group="started")), + ) + .order_by("project_id") + ) + + return ( + base_queryset.annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, then="assignees__avatar" + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") + .annotate( + cancelled_work_items=Count( + "id", filter=Q(state__group="cancelled"), distinct=True + ), + completed_work_items=Count( + "id", filter=Q(state__group="completed"), distinct=True + ), + backlog_work_items=Count( + "id", filter=Q(state__group="backlog"), distinct=True + ), + un_started_work_items=Count( + "id", filter=Q(state__group="unstarted"), distinct=True + ), + started_work_items=Count( + "id", filter=Q(state__group="started"), distinct=True + ), + ) + .order_by("display_name") + ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request: HttpRequest, slug: str) -> Response: self.initialize_workspace(slug, type="chart") type = request.GET.get("type", "work-items") if type == "work-items": + # Optionally accept cycle_id or module_id as query params + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) + peek_view = request.GET.get("peek_view", False) return Response( - self.get_project_issues_stats(), + self.get_work_items_stats( + cycle_id=cycle_id, module_id=module_id, peek_view=peek_view + ), status=status.HTTP_200_OK, ) @@ -251,7 +353,9 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): for key, value in data.items() ] - def work_item_completion_chart(self) -> Dict[str, Any]: + def work_item_completion_chart( + self, cycle_id=None, module_id=None, peek_view=False + ) -> Dict[str, Any]: # Get the base queryset queryset = ( Issue.issue_objects.filter(**self.filters["base_filters"]) @@ -261,61 +365,143 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): ) ) - # Apply date range filter if available - if self.filters["chart_period_range"]: - start_date, end_date = self.filters["chart_period_range"] - queryset = queryset.filter( - created_at__date__gte=start_date, created_at__date__lte=end_date - ) - - # Annotate by month and count - monthly_stats = ( - queryset.annotate(month=TruncMonth("created_at")) - .values("month") - .annotate( - created_count=Count("id"), - completed_count=Count("id", filter=Q(completed_at__isnull=False)), - ) - .order_by("month") - ) - - # Create dictionary of month -> counts - stats_dict = { - stat["month"].strftime("%Y-%m-%d"): { - "created_count": stat["created_count"], - "completed_count": stat["completed_count"], - } - for stat in monthly_stats - } - - # Generate monthly data (ensure months with 0 count are included) - data = [] - workspace = Workspace.objects.get(slug=self._workspace_slug) - start_date = workspace.created_at.date().replace(day=1) - # include the current date at the end - end_date = timezone.now().date() - last_month = end_date.replace(day=1) - current_month = start_date - - while current_month <= last_month: - date_str = current_month.strftime("%Y-%m-%d") - stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0}) - data.append( - { - "key": date_str, - "name": date_str, - "count": stats[ - "created_count" - ], # <- Total created issues in that month - "completed_issues": stats["completed_count"], - "created_issues": stats["created_count"], - } - ) - # Move to next month - if current_month.month == 12: - current_month = current_month.replace(year=current_month.year + 1, month=1) + if cycle_id is not None and peek_view: + cycle_issues = CycleIssue.objects.filter( + **self.filters["base_filters"], cycle_id=cycle_id + ).values_list("issue_id", flat=True) + cycle = Cycle.objects.filter(id=cycle_id).first() + if cycle and cycle.start_date: + start_date = cycle.start_date.date() + end_date = cycle.end_date.date() else: - current_month = current_month.replace(month=current_month.month + 1) + return {"data": [], "schema": {}} + queryset = cycle_issues + elif module_id is not None and peek_view: + module_issues = ModuleIssue.objects.filter( + **self.filters["base_filters"], module_id=module_id + ).values_list("issue_id", flat=True) + module = Module.objects.filter(id=module_id).first() + if module and module.start_date: + start_date = module.start_date + end_date = module.target_date + else: + return {"data": [], "schema": {}} + queryset = module_issues + elif peek_view: + project_ids_str = self.request.GET.get("project_ids") + if project_ids_str: + project_id_list = [ + pid.strip() for pid in project_ids_str.split(",") if pid.strip() + ] + else: + project_id_list = [] + return {"data": [], "schema": {}} + project_id = project_id_list[0] + project = Project.objects.filter(id=project_id).first() + if project.created_at: + start_date = project.created_at.date().replace(day=1) + else: + return {"data": [], "schema": {}} + else: + workspace = Workspace.objects.get(slug=self._workspace_slug) + start_date = workspace.created_at.date().replace(day=1) + + if cycle_id or module_id: + # Get daily stats with optimized query + daily_stats = ( + queryset.values("created_at__date") + .annotate( + created_count=Count("id"), + completed_count=Count( + "id", filter=Q(issue__state__group="completed") + ), + ) + .order_by("created_at__date") + ) + + # Create a dictionary of existing stats with summed counts + stats_dict = { + stat["created_at__date"].strftime("%Y-%m-%d"): { + "created_count": stat["created_count"], + "completed_count": stat["completed_count"], + } + for stat in daily_stats + } + + # Generate data for all days in the range + data = [] + current_date = start_date + while current_date <= end_date: + date_str = current_date.strftime("%Y-%m-%d") + stats = stats_dict.get( + date_str, {"created_count": 0, "completed_count": 0} + ) + data.append( + { + "key": date_str, + "name": date_str, + "count": stats["created_count"] + stats["completed_count"], + "completed_issues": stats["completed_count"], + "created_issues": stats["created_count"], + } + ) + current_date += timedelta(days=1) + else: + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + queryset = queryset.filter( + created_at__date__gte=start_date, created_at__date__lte=end_date + ) + + # Annotate by month and count + monthly_stats = ( + queryset.annotate(month=TruncMonth("created_at")) + .values("month") + .annotate( + created_count=Count("id"), + completed_count=Count("id", filter=Q(state__group="completed")), + ) + .order_by("month") + ) + + # Create dictionary of month -> counts + stats_dict = { + stat["month"].strftime("%Y-%m-%d"): { + "created_count": stat["created_count"], + "completed_count": stat["completed_count"], + } + for stat in monthly_stats + } + + # Generate monthly data (ensure months with 0 count are included) + data = [] + # include the current date at the end + end_date = timezone.now().date() + last_month = end_date.replace(day=1) + current_month = start_date + + while current_month <= last_month: + date_str = current_month.strftime("%Y-%m-%d") + stats = stats_dict.get( + date_str, {"created_count": 0, "completed_count": 0} + ) + data.append( + { + "key": date_str, + "name": date_str, + "count": stats["created_count"], + "completed_issues": stats["completed_count"], + "created_issues": stats["created_count"], + } + ) + # Move to next month + if current_month.month == 12: + current_month = current_month.replace( + year=current_month.year + 1, month=1 + ) + else: + current_month = current_month.replace(month=current_month.month + 1) schema = { "completed_issues": "completed_issues", @@ -330,12 +516,13 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): type = request.GET.get("type", "projects") group_by = request.GET.get("group_by", None) x_axis = request.GET.get("x_axis", "PRIORITY") + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) if type == "projects": return Response(self.project_chart(), status=status.HTTP_200_OK) elif type == "custom-work-items": - # Get the base queryset queryset = ( Issue.issue_objects.filter(**self.filters["base_filters"]) .select_related("workspace", "state", "parent") @@ -344,6 +531,19 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): ) ) + # Apply cycle/module filters if present + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter( + **self.filters["base_filters"], cycle_id=cycle_id + ).values_list("issue_id", flat=True) + queryset = queryset.filter(id__in=cycle_issues) + + elif module_id is not None: + module_issues = ModuleIssue.objects.filter( + **self.filters["base_filters"], module_id=module_id + ).values_list("issue_id", flat=True) + queryset = queryset.filter(id__in=module_issues) + # Apply date range filter if available if self.filters["chart_period_range"]: start_date, end_date = self.filters["chart_period_range"] @@ -357,66 +557,15 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): ) elif type == "work-items": + # Optionally accept cycle_id or module_id as query params + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) + peek_view = request.GET.get("peek_view", False) return Response( - self.work_item_completion_chart(), + self.work_item_completion_chart( + cycle_id=cycle_id, module_id=module_id, peek_view=peek_view + ), status=status.HTTP_200_OK, ) return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST) - - -class AdvanceAnalyticsExportEndpoint(AdvanceAnalyticsBaseView): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") - def post(self, request: HttpRequest, slug: str) -> Response: - self.initialize_workspace(slug, type="chart") - queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) - - # Apply date range filter if available - if self.filters["chart_period_range"]: - start_date, end_date = self.filters["chart_period_range"] - queryset = queryset.filter( - created_at__date__gte=start_date, created_at__date__lte=end_date - ) - - queryset = ( - queryset.values("project_id", "project__name") - .annotate( - cancelled_work_items=Count("id", filter=Q(state__group="cancelled")), - completed_work_items=Count("id", filter=Q(state__group="completed")), - backlog_work_items=Count("id", filter=Q(state__group="backlog")), - un_started_work_items=Count("id", filter=Q(state__group="unstarted")), - started_work_items=Count("id", filter=Q(state__group="started")), - ) - .order_by("project_id") - ) - - # Convert QuerySet to list of dictionaries for serialization - serialized_data = list(queryset) - - headers = [ - "Projects", - "Completed Issues", - "Backlog Issues", - "Unstarted Issues", - "Started Issues", - ] - - keys = [ - "project__name", - "completed_work_items", - "backlog_work_items", - "un_started_work_items", - "started_work_items", - ] - - email = request.user.email - - # Send serialized data to background task - export_analytics_to_csv_email.delay(serialized_data, headers, keys, email, slug) - - return Response( - { - "message": f"Once the export is ready it will be emailed to you at {str(email)}" - }, - status=status.HTTP_200_OK, - ) diff --git a/apiserver/plane/utils/date_utils.py b/apiserver/plane/utils/date_utils.py index 5726fbfd1..4225e70b5 100644 --- a/apiserver/plane/utils/date_utils.py +++ b/apiserver/plane/utils/date_utils.py @@ -174,8 +174,8 @@ def get_analytics_filters( "workspace__slug": slug, "project_projectmember__member": user, "project_projectmember__is_active": True, - "project__deleted_at__isnull": True, - "project__archived_at__isnull": True, + "deleted_at__isnull": True, + "archived_at__isnull": True, } # Add project IDs to filters if provided diff --git a/packages/types/src/analytics-v2.d.ts b/packages/types/src/analytics-v2.d.ts index 176cd1191..1a8652b70 100644 --- a/packages/types/src/analytics-v2.d.ts +++ b/packages/types/src/analytics-v2.d.ts @@ -1,52 +1,55 @@ import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/constants"; import { TChartData } from "./charts"; -export type TAnalyticsTabsV2Base = "overview" | "work-items" -export type TAnalyticsGraphsV2Base = "projects" | "work-items" | "custom-work-items" - +export type TAnalyticsTabsV2Base = "overview" | "work-items"; +export type TAnalyticsGraphsV2Base = "projects" | "work-items" | "custom-work-items"; // service types export interface IAnalyticsResponseV2 { - [key: string]: any; + [key: string]: any; } export interface IAnalyticsResponseFieldsV2 { - count: number; - filter_count: number; + count: number; + filter_count: number; } export interface IAnalyticsRadarEntityV2 { - key: string, - name: string, - count: number + key: string; + name: string; + count: number; } // chart types export interface IChartResponseV2 { - schema: Record; - data: TChartData[]; + schema: Record; + data: TChartData[]; } // table types export interface WorkItemInsightColumns { - project_id: string; - project__name: string; - cancelled_work_items: number; - completed_work_items: number; - backlog_work_items: number; - un_started_work_items: number; - started_work_items: number; + project_id?: string; + project__name?: string; + cancelled_work_items: number; + completed_work_items: number; + backlog_work_items: number; + un_started_work_items: number; + started_work_items: number; + // because of the peek view, we will display the name of the project instead of project__name + display_name?: string; + avatar_url?: string; + assignee_id?: string; } export type AnalyticsTableDataMap = { - "work-items": WorkItemInsightColumns, -} + "work-items": WorkItemInsightColumns; +}; export interface IAnalyticsV2Params { - x_axis: ChartXAxisProperty; - y_axis: ChartYAxisMetric; - group_by?: ChartXAxisProperty; -} \ No newline at end of file + x_axis: ChartXAxisProperty; + y_axis: ChartYAxisMetric; + group_by?: ChartXAxisProperty; +} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx index ebe492584..07dd0fc4d 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -27,7 +27,7 @@ import { // ui import { Breadcrumbs, Button, ContrastIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui"; // components -import { ProjectAnalyticsModal } from "@/components/analytics"; +import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal"; import { BreadcrumbLink, SwitcherLabel } from "@/components/common"; import { CycleQuickActions } from "@/components/cycles"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; @@ -161,7 +161,8 @@ export const CycleIssuesHeader: React.FC = observer(() => { return ( <> - setAnalyticsModal(false)} cycleDetails={cycleDetails ?? undefined} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx index 31eb5b249..4efa986b4 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx @@ -19,7 +19,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption // ui import { CustomMenu } from "@plane/ui"; // components -import { ProjectAnalyticsModal } from "@/components/analytics"; +import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal"; import { DisplayFiltersSelection, FilterSelection, FiltersDropdown, IssueLayoutIcon } from "@/components/issues"; // helpers import { isIssueFilterActive } from "@/helpers/filter.helper"; @@ -123,7 +123,8 @@ export const CycleIssuesMobileHeader = () => { return ( <> - setAnalyticsModal(false)} cycleDetails={cycleDetails ?? undefined} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx index 6bbbb29a0..6830b5532 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx @@ -25,7 +25,7 @@ import { // ui import { Breadcrumbs, Button, DiceIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui"; // components -import { ProjectAnalyticsModal } from "@/components/analytics"; +import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal"; import { BreadcrumbLink, SwitcherLabel } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // helpers @@ -155,10 +155,11 @@ export const ModuleIssuesHeader: React.FC = observer(() => { return ( <> - setAnalyticsModal(false)} moduleDetails={moduleDetails ?? undefined} + projectDetails={currentProjectDetails} />
diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx index 1f000bae2..741fe3b53 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx @@ -21,6 +21,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption import { CustomMenu } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; +import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal"; import { DisplayFiltersSelection, FilterSelection, @@ -106,10 +107,11 @@ export const ModuleIssuesMobileHeader = observer(() => { return (
- setAnalyticsModal(false)} moduleDetails={moduleDetails ?? undefined} + projectDetails={currentProjectDetails} />
const { data, isLoading, columns, columnsLabels } = props; const params = useParams(); const { t } = useTranslation(); - const workspaceSlug = params.workspaceSlug as string; + const workspaceSlug = params.workspaceSlug.toString(); if (isLoading) { return ; } @@ -35,7 +35,7 @@ export const InsightTable = const exportCSV = (rows: Row[]) => { const rowData: any = rows.map((row) => { - const { project_id, ...exportableData } = row.original; + const { project_id, avatar_url, assignee_id, ...exportableData } = row.original; return Object.fromEntries( Object.entries(exportableData).map(([key, value]) => { if (columnsLabels?.[key]) { diff --git a/web/core/components/analytics-v2/overview/project-insights.tsx b/web/core/components/analytics-v2/overview/project-insights.tsx index fa156be6a..83054a885 100644 --- a/web/core/components/analytics-v2/overview/project-insights.tsx +++ b/web/core/components/analytics-v2/overview/project-insights.tsx @@ -26,16 +26,20 @@ const analyticsV2Service = new AnalyticsV2Service(); const ProjectInsights = observer(() => { const params = useParams(); const { t } = useTranslation(); - const workspaceSlug = params.workspaceSlug as string; - const { selectedDuration, selectedDurationLabel, selectedProjects } = useAnalyticsV2(); + const workspaceSlug = params.workspaceSlug.toString(); + const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } = + useAnalyticsV2(); const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-radar" }); const { data: projectInsightsData, isLoading: isLoadingProjectInsight } = useSWR( - `radar-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}`, + `radar-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`, () => analyticsV2Service.getAdvanceAnalyticsCharts[]>(workspaceSlug, "projects", { // date_filter: selectedDuration, ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + ...(isPeekView ? { peek_view: true } : {}), }) ); diff --git a/web/core/components/analytics-v2/total-insights.tsx b/web/core/components/analytics-v2/total-insights.tsx index 09998feaf..8a3ffe8b7 100644 --- a/web/core/components/analytics-v2/total-insights.tsx +++ b/web/core/components/analytics-v2/total-insights.tsx @@ -18,16 +18,20 @@ const analyticsV2Service = new AnalyticsV2Service(); const TotalInsights: React.FC<{ analyticsType: TAnalyticsTabsV2Base; peekView?: boolean }> = observer( ({ analyticsType, peekView }) => { const params = useParams(); - const workspaceSlug = params.workspaceSlug as string; + const workspaceSlug = params.workspaceSlug.toString(); const { t } = useTranslation(); - const { selectedDuration, selectedProjects, selectedDurationLabel } = useAnalyticsV2(); + const { selectedDuration, selectedProjects, selectedDurationLabel, selectedCycle, selectedModule, isPeekView } = + useAnalyticsV2(); const { data: totalInsightsData, isLoading } = useSWR( - `total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}`, + `total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`, () => analyticsV2Service.getAdvanceAnalytics(workspaceSlug, analyticsType, { // date_filter: selectedDuration, ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + ...(isPeekView ? { peek_view: true } : {}), }) ); return ( diff --git a/web/core/components/analytics-v2/work-items/created-vs-resolved.tsx b/web/core/components/analytics-v2/work-items/created-vs-resolved.tsx index 2c95b916d..873ac2ed7 100644 --- a/web/core/components/analytics-v2/work-items/created-vs-resolved.tsx +++ b/web/core/components/analytics-v2/work-items/created-vs-resolved.tsx @@ -19,17 +19,21 @@ import { ChartLoader } from "../loaders"; const analyticsV2Service = new AnalyticsV2Service(); const CreatedVsResolved = observer(() => { - const { selectedDuration, selectedDurationLabel, selectedProjects } = useAnalyticsV2(); + const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } = + useAnalyticsV2(); const params = useParams(); const { t } = useTranslation(); - const workspaceSlug = params.workspaceSlug as string; + const workspaceSlug = params.workspaceSlug.toString(); const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-area" }); const { data: createdVsResolvedData, isLoading: isCreatedVsResolvedLoading } = useSWR( - `created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}`, + `created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`, () => analyticsV2Service.getAdvanceAnalyticsCharts(workspaceSlug, "work-items", { // date_filter: selectedDuration, ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + ...(isPeekView ? { peek_view: true } : {}), }) ); const parsedData: TChartData[] = useMemo(() => { diff --git a/web/core/components/analytics-v2/work-items/modal/content.tsx b/web/core/components/analytics-v2/work-items/modal/content.tsx index 85004d9af..62b3c24ba 100644 --- a/web/core/components/analytics-v2/work-items/modal/content.tsx +++ b/web/core/components/analytics-v2/work-items/modal/content.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { Tab } from "@headlessui/react"; // plane package imports -import { IProject } from "@plane/types"; +import { ICycle, IModule, IProject } from "@plane/types"; import { Spinner } from "@plane/ui"; // hooks import { useAnalyticsV2 } from "@/hooks/store"; @@ -15,20 +15,52 @@ import WorkItemsInsightTable from "../workitems-insight-table"; type Props = { fullScreen: boolean; projectDetails: IProject | undefined; + cycleDetails: ICycle | undefined; + moduleDetails: IModule | undefined; }; export const WorkItemsModalMainContent: React.FC = observer((props) => { - const { projectDetails, fullScreen } = props; - const { updateSelectedProjects } = useAnalyticsV2(); - const [isProjectConfigured, setIsProjectConfigured] = useState(false); + const { projectDetails, cycleDetails, moduleDetails, fullScreen } = props; + const { updateSelectedProjects, updateSelectedCycle, updateSelectedModule, updateIsPeekView } = useAnalyticsV2(); + const [isModalConfigured, setIsModalConfigured] = useState(false); useEffect(() => { - if (!projectDetails?.id) return; - updateSelectedProjects([projectDetails?.id ?? ""]); - setIsProjectConfigured(true); - }, [projectDetails?.id, updateSelectedProjects]); + updateIsPeekView(true); - if (!isProjectConfigured) + // Handle project selection + if (projectDetails?.id) { + updateSelectedProjects([projectDetails.id]); + } + + // Handle cycle selection + if (cycleDetails?.id) { + updateSelectedCycle(cycleDetails.id); + } + + // Handle module selection + if (moduleDetails?.id) { + updateSelectedModule(moduleDetails.id); + } + setIsModalConfigured(true); + + // Cleanup fields + return () => { + updateSelectedProjects([]); + updateSelectedCycle(""); + updateSelectedModule(""); + updateIsPeekView(false); + }; + }, [ + projectDetails?.id, + cycleDetails?.id, + moduleDetails?.id, + updateSelectedProjects, + updateSelectedCycle, + updateSelectedModule, + updateIsPeekView, + ]); + + if (!isModalConfigured) return (
diff --git a/web/core/components/analytics-v2/work-items/modal/header.tsx b/web/core/components/analytics-v2/work-items/modal/header.tsx index f4bcdee38..1aa2c1b66 100644 --- a/web/core/components/analytics-v2/work-items/modal/header.tsx +++ b/web/core/components/analytics-v2/work-items/modal/header.tsx @@ -1,21 +1,26 @@ import { observer } from "mobx-react"; - -// icons +// plane package imports import { Expand, Shrink, X } from "lucide-react"; +import { ICycle, IModule } from "@plane/types"; +// icons type Props = { fullScreen: boolean; handleClose: () => void; setFullScreen: React.Dispatch>; title: string; + cycle?: ICycle; + module?: IModule; }; export const WorkItemsModalHeader: React.FC = observer((props) => { - const { fullScreen, handleClose, setFullScreen, title } = props; + const { fullScreen, handleClose, setFullScreen, title, cycle, module } = props; return (
-

Analytics for {title}

+

+ Analytics for {title} {cycle && `in ${cycle.name}`} {module && `in ${module.name}`} +

diff --git a/web/core/components/analytics-v2/work-items/priority-chart.tsx b/web/core/components/analytics-v2/work-items/priority-chart.tsx index 5e81c7efa..664824851 100644 --- a/web/core/components/analytics-v2/work-items/priority-chart.tsx +++ b/web/core/components/analytics-v2/work-items/priority-chart.tsx @@ -46,19 +46,23 @@ const PriorityChart = observer((props: Props) => { const { t } = useTranslation(); const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-bar" }); // store hooks - const { selectedDuration, selectedProjects } = useAnalyticsV2(); + const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView } = useAnalyticsV2(); const { workspaceStates } = useProjectState(); const { resolvedTheme } = useTheme(); // router const params = useParams(); - const workspaceSlug = params.workspaceSlug as string; + const workspaceSlug = params.workspaceSlug.toString(); const { data: priorityChartData, isLoading: priorityChartLoading } = useSWR( - `customized-insights-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${props.x_axis}-${props.y_axis}-${props.group_by}`, + `customized-insights-chart-${workspaceSlug}-${selectedDuration}- + ${selectedProjects}-${selectedCycle}-${selectedModule}-${props.x_axis}-${props.y_axis}-${props.group_by}-${isPeekView}`, () => analyticsV2Service.getAdvanceAnalyticsCharts(workspaceSlug, "custom-work-items", { // date_filter: selectedDuration, ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + ...(isPeekView ? { peek_view: true } : {}), ...props, }) ); @@ -158,10 +162,23 @@ const PriorityChart = observer((props: Props) => { }); const exportCSV = (rows: Row[]) => { - const rowData = rows.map((row) => ({ - name: row.original.name, - count: row.original.count, - })); + const rowData = rows.map((row) => { + const hiddenFields = ["key", "avatar_url", "assignee_id", "project_id"]; + const otherFields = Object.keys(row.original).filter( + (key) => key !== "name" && key !== "count" && !hiddenFields.includes(key) && !key.includes("id") + ); + return { + name: row.original.name, + count: row.original.count, + ...otherFields.reduce( + (acc, key) => { + acc[parsedData?.schema[key] ?? key] = row.original[key]; + return acc; + }, + {} as Record + ), + }; + }); const csv = generateCsv(csvConfig)(rowData); download(csvConfig)(csv); }; diff --git a/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx b/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx index 85c93676b..e42824492 100644 --- a/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx +++ b/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx @@ -1,13 +1,15 @@ import { useMemo } from "react"; -import { ColumnDef } from "@tanstack/react-table"; +import { ColumnDef, Row } from "@tanstack/react-table"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; -import { Briefcase } from "lucide-react"; +import { Briefcase, UserRound } from "lucide-react"; // plane package imports import { useTranslation } from "@plane/i18n"; import { WorkItemInsightColumns, AnalyticsTableDataMap } from "@plane/types"; // plane web components +import { Avatar } from "@plane/ui"; +import { getFileURL } from "@plane/utils"; import { Logo } from "@/components/common/logo"; // hooks import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2"; @@ -21,44 +23,85 @@ const analyticsV2Service = new AnalyticsV2Service(); const WorkItemsInsightTable = observer(() => { // router const params = useParams(); - const workspaceSlug = params.workspaceSlug as string; + const workspaceSlug = params.workspaceSlug.toString(); const { t } = useTranslation(); // store hooks const { getProjectById } = useProject(); - const { selectedDuration, selectedProjects } = useAnalyticsV2(); + const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView } = useAnalyticsV2(); const { data: workItemsData, isLoading } = useSWR( - `insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}`, + `insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`, () => analyticsV2Service.getAdvanceAnalyticsStats(workspaceSlug, "work-items", { // date_filter: selectedDuration, ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + ...(isPeekView ? { peek_view: true } : {}), }) ); // derived values - const columnsLabels: Record = { - backlog_work_items: t("workspace_projects.state.backlog"), - started_work_items: t("workspace_projects.state.started"), - un_started_work_items: t("workspace_projects.state.unstarted"), - completed_work_items: t("workspace_projects.state.completed"), - cancelled_work_items: t("workspace_projects.state.cancelled"), - project__name: t("common.project"), - }; + const columnsLabels = useMemo( + () => ({ + backlog_work_items: t("workspace_projects.state.backlog"), + started_work_items: t("workspace_projects.state.started"), + un_started_work_items: t("workspace_projects.state.unstarted"), + completed_work_items: t("workspace_projects.state.completed"), + cancelled_work_items: t("workspace_projects.state.cancelled"), + project__name: t("common.project"), + display_name: t("common.assignee"), + }), + [t] + ); const columns = useMemo( () => [ - { - accessorKey: "project__name", - header: () =>
{columnsLabels["project__name"]}
, - cell: ({ row }) => { - const project = getProjectById(row.original.project_id); - return ( -
- {project?.logo_props ? : } - {project?.name} -
- ); - }, - }, + !isPeekView + ? { + accessorKey: "project__name", + header: () =>
{columnsLabels["project__name"]}
, + cell: ({ row }) => { + const project = getProjectById(row.original.project_id); + return ( +
+ {project?.logo_props ? ( + + ) : ( + + )} + {project?.name} +
+ ); + }, + } + : { + accessorKey: "display_name", + header: () =>
{columnsLabels["display_name"]}
, + cell: ({ row }: { row: Row }) => ( +
+
+ {row.original.avatar_url && row.original.avatar_url !== "" ? ( + + ) : ( +
+ {row.original.display_name ? ( + row.original.display_name?.[0] + ) : ( + + )} +
+ )} + + {row.original.display_name ?? t(`Unassigned`)} + +
+
+ ), + }, { accessorKey: "backlog_work_items", header: () =>
{columnsLabels["backlog_work_items"]}
, @@ -85,7 +128,7 @@ const WorkItemsInsightTable = observer(() => { cell: ({ row }) =>
{row.original.cancelled_work_items}
, }, ] as ColumnDef[], - [getProjectById] + [columnsLabels, getProjectById, isPeekView, t] ); return ( diff --git a/web/core/components/analytics/old-page.tsx b/web/core/components/analytics/old-page.tsx new file mode 100644 index 000000000..719d66214 --- /dev/null +++ b/web/core/components/analytics/old-page.tsx @@ -0,0 +1,107 @@ +"use client"; + +import React, { Fragment } from "react"; +import { observer } from "mobx-react"; +import { useSearchParams } from "next/navigation"; +import { Tab } from "@headlessui/react"; +// plane package imports +import { ANALYTICS_TABS, EUserPermissionsLevel, EUserPermissions } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { Header, EHeaderVariant } from "@plane/ui"; +// components +import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics"; +import { PageHead } from "@/components/core"; +import { ComicBoxButton, DetailedEmptyState } from "@/components/empty-state"; +// hooks +import { useCommandPalette, useEventTracker, useProject, useUserPermissions, useWorkspace } from "@/hooks/store"; +import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; + +const OldAnalyticsPage = observer(() => { + const searchParams = useSearchParams(); + const analytics_tab = searchParams.get("analytics_tab"); + // plane imports + const { t } = useTranslation(); + // store hooks + const { toggleCreateProjectModal } = useCommandPalette(); + const { setTrackElement } = useEventTracker(); + const { workspaceProjectIds, loader } = useProject(); + const { currentWorkspace } = useWorkspace(); + const { allowPermissions } = useUserPermissions(); + // helper hooks + const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/analytics" }); + // derived values + const pageTitle = currentWorkspace?.name + ? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name }) + : undefined; + + // permissions + const canPerformEmptyStateActions = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + // TODO: refactor loader implementation + return ( + <> + + {workspaceProjectIds && ( + <> + {workspaceProjectIds.length > 0 || loader === "init-loader" ? ( +
+ +
+ + {ANALYTICS_TABS.map((tab) => ( + + {({ selected }) => ( + + )} + + ))} + +
+ + + + + + + + +
+
+ ) : ( + { + setTrackElement("Analytics empty state"); + toggleCreateProjectModal(true); + }} + disabled={!canPerformEmptyStateActions} + /> + } + /> + )} + + )} + + ); +}); + +export default OldAnalyticsPage; diff --git a/web/core/store/analytics-v2.store.ts b/web/core/store/analytics-v2.store.ts index bf8f91a72..97582577a 100644 --- a/web/core/store/analytics-v2.store.ts +++ b/web/core/store/analytics-v2.store.ts @@ -10,6 +10,9 @@ export interface IAnalyticsStoreV2 { currentTab: TAnalyticsTabsV2Base; selectedProjects: string[]; selectedDuration: DurationType; + selectedCycle: string; + selectedModule: string; + isPeekView?: boolean; //computed selectedDurationLabel: DurationType | null; @@ -17,25 +20,36 @@ export interface IAnalyticsStoreV2 { //actions updateSelectedProjects: (projects: string[]) => void; updateSelectedDuration: (duration: DurationType) => void; + updateSelectedCycle: (cycle: string) => void; + updateSelectedModule: (module: string) => void; + updateIsPeekView: (isPeekView: boolean) => void; } export class AnalyticsStoreV2 implements IAnalyticsStoreV2 { //observables currentTab: TAnalyticsTabsV2Base = "overview"; - selectedProjects: DurationType[] = []; + selectedProjects: string[] = []; selectedDuration: DurationType = "last_30_days"; - - constructor(_rootStore: CoreRootStore) { + selectedCycle: string = ""; + selectedModule: string = ""; + isPeekView: boolean = false; + constructor() { makeObservable(this, { // observables currentTab: observable.ref, selectedDuration: observable.ref, selectedProjects: observable.ref, + selectedCycle: observable.ref, + selectedModule: observable.ref, + isPeekView: observable.ref, // computed selectedDurationLabel: computed, // actions updateSelectedProjects: action, updateSelectedDuration: action, + updateSelectedCycle: action, + updateSelectedModule: action, + updateIsPeekView: action, }); } @@ -44,7 +58,6 @@ export class AnalyticsStoreV2 implements IAnalyticsStoreV2 { } updateSelectedProjects = (projects: string[]) => { - const initialState = this.selectedProjects; try { runInAction(() => { this.selectedProjects = projects; @@ -65,4 +78,22 @@ export class AnalyticsStoreV2 implements IAnalyticsStoreV2 { throw error; } }; + + updateSelectedCycle = (cycle: string) => { + runInAction(() => { + this.selectedCycle = cycle; + }); + }; + + updateSelectedModule = (module: string) => { + runInAction(() => { + this.selectedModule = module; + }); + }; + + updateIsPeekView = (isPeekView: boolean) => { + runInAction(() => { + this.isPeekView = isPeekView; + }); + }; } diff --git a/web/core/store/root.store.ts b/web/core/store/root.store.ts index d2355de78..2aef8d030 100644 --- a/web/core/store/root.store.ts +++ b/web/core/store/root.store.ts @@ -96,7 +96,7 @@ export class CoreRootStore { this.transient = new TransientStore(); this.stickyStore = new StickyStore(); this.editorAssetStore = new EditorAssetStore(); - this.analyticsV2 = new AnalyticsStoreV2(this); + this.analyticsV2 = new AnalyticsStoreV2(); } resetOnSignOut() {