diff --git a/apiserver/plane/app/views/auth_extended.py b/apiserver/plane/app/views/auth_extended.py index 501f47657..29cb43e38 100644 --- a/apiserver/plane/app/views/auth_extended.py +++ b/apiserver/plane/app/views/auth_extended.py @@ -401,8 +401,8 @@ class EmailCheckEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", + event_name="Sign up", + medium="Magic link", first_time=True, ) key, token, current_attempt = generate_magic_token(email=email) @@ -438,8 +438,8 @@ class EmailCheckEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", + event_name="Sign in", + medium="Magic link", first_time=False, ) @@ -468,8 +468,8 @@ class EmailCheckEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="EMAIL", + event_name="Sign in", + medium="Email", first_time=False, ) diff --git a/apiserver/plane/app/views/authentication.py b/apiserver/plane/app/views/authentication.py index a41200d61..c2b3e0b7e 100644 --- a/apiserver/plane/app/views/authentication.py +++ b/apiserver/plane/app/views/authentication.py @@ -274,8 +274,8 @@ class SignInEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="EMAIL", + event_name="Sign in", + medium="Email", first_time=False, ) @@ -349,8 +349,8 @@ class MagicSignInEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", + event_name="Sign in", + medium="Magic link", first_time=False, ) diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 3c13fa49e..93bbc9cd7 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -20,6 +20,7 @@ from django.core import serializers from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page +from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework.response import Response @@ -312,6 +313,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "labels": label_distribution, "completion_chart": {}, } + if data[0]["start_date"] and data[0]["end_date"]: data[0]["distribution"][ "completion_chart" @@ -840,10 +842,230 @@ class TransferCycleIssueEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - new_cycle = Cycle.objects.get( + new_cycle = Cycle.objects.filter( workspace__slug=slug, project_id=project_id, pk=new_cycle_id + ).first() + + old_cycle = ( + Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ) + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + total_estimates=Sum("issue_cycle__issue__estimate_point") + ) + .annotate( + completed_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) ) + # Pass the new_cycle queryset to burndown_plot + completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + cycle_id=cycle_id, + ) + + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_issues=Count( + "id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "id", + filter=Q(archived_at__isnull=True, is_draft=False), + ) + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + assignee_distribution_data = [ + { + "display_name": item["display_name"], + "assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None, + "avatar": item["avatar"], + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in assignee_distribution + ] + + label_distribution_data = [ + { + "label_name": item["label_name"], + "color": item["color"], + "label_id": str(item["label_id"]) if item["label_id"] else None, + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in label_distribution + ] + + current_cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ).first() + + current_cycle.progress_snapshot = { + "total_issues": old_cycle.first().total_issues, + "completed_issues": old_cycle.first().completed_issues, + "cancelled_issues": old_cycle.first().cancelled_issues, + "started_issues": old_cycle.first().started_issues, + "unstarted_issues": old_cycle.first().unstarted_issues, + "backlog_issues": old_cycle.first().backlog_issues, + "total_estimates": old_cycle.first().total_estimates, + "completed_estimates": old_cycle.first().completed_estimates, + "started_estimates": old_cycle.first().started_estimates, + "distribution":{ + "labels": label_distribution_data, + "assignees": assignee_distribution_data, + "completion_chart": completion_chart, + }, + } + current_cycle.save(update_fields=["progress_snapshot"]) + if ( new_cycle.end_date is not None and new_cycle.end_date < timezone.now().date() diff --git a/apiserver/plane/app/views/oauth.py b/apiserver/plane/app/views/oauth.py index de90e4337..8152fb0ee 100644 --- a/apiserver/plane/app/views/oauth.py +++ b/apiserver/plane/app/views/oauth.py @@ -296,7 +296,7 @@ class OauthEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", + event_name="Sign in", medium=medium.upper(), first_time=False, ) @@ -427,7 +427,7 @@ class OauthEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", + event_name="Sign up", medium=medium.upper(), first_time=True, ) diff --git a/apiserver/plane/db/migrations/0059_auto_20240208_0957.py b/apiserver/plane/db/migrations/0059_auto_20240208_0957.py new file mode 100644 index 000000000..c4c43fa4b --- /dev/null +++ b/apiserver/plane/db/migrations/0059_auto_20240208_0957.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.7 on 2024-02-08 09:57 + +from django.db import migrations + + +def widgets_filter_change(apps, schema_editor): + Widget = apps.get_model("db", "Widget") + widgets_to_update = [] + + # Define the filter dictionaries for each widget key + filters_mapping = { + "assigned_issues": {"duration": "none", "tab": "pending"}, + "created_issues": {"duration": "none", "tab": "pending"}, + "issues_by_state_groups": {"duration": "none"}, + "issues_by_priority": {"duration": "none"}, + } + + # Iterate over widgets and update filters if applicable + for widget in Widget.objects.all(): + if widget.key in filters_mapping: + widget.filters = filters_mapping[widget.key] + widgets_to_update.append(widget) + + # Bulk update the widgets + Widget.objects.bulk_update(widgets_to_update, ["filters"], batch_size=10) + +class Migration(migrations.Migration): + dependencies = [ + ('db', '0058_alter_moduleissue_issue_and_more'), + ] + operations = [ + migrations.RunPython(widgets_filter_change) + ] diff --git a/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py b/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py new file mode 100644 index 000000000..074e20a16 --- /dev/null +++ b/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2024-02-08 09:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0059_auto_20240208_0957'), + ] + + operations = [ + migrations.AddField( + model_name='cycle', + name='progress_snapshot', + field=models.JSONField(default=dict), + ), + ] diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index 5251c68ec..d802dbc1e 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -68,6 +68,7 @@ class Cycle(ProjectBaseModel): sort_order = models.FloatField(default=65535) external_source = models.CharField(max_length=255, null=True, blank=True) external_id = models.CharField(max_length=255, blank=True, null=True) + progress_snapshot = models.JSONField(default=dict) class Meta: verbose_name = "Cycle" diff --git a/packages/types/src/cycles.d.ts b/packages/types/src/cycles.d.ts index 12cbab4c6..5d715385a 100644 --- a/packages/types/src/cycles.d.ts +++ b/packages/types/src/cycles.d.ts @@ -31,6 +31,7 @@ export interface ICycle { issue: string; name: string; owned_by: string; + progress_snapshot: TProgressSnapshot; project: string; project_detail: IProjectLite; status: TCycleGroups; @@ -49,6 +50,23 @@ export interface ICycle { workspace_detail: IWorkspaceLite; } +export type TProgressSnapshot = { + backlog_issues: number; + cancelled_issues: number; + completed_estimates: number | null; + completed_issues: number; + distribution?: { + assignees: TAssigneesDistribution[]; + completion_chart: TCompletionChartDistribution; + labels: TLabelsDistribution[]; + }; + started_estimates: number | null; + started_issues: number; + total_estimates: number | null; + total_issues: number; + unstarted_issues: number; +}; + export type TAssigneesDistribution = { assignee_id: string | null; avatar: string | null; diff --git a/packages/types/src/dashboard.d.ts b/packages/types/src/dashboard.d.ts index 7cfa6aa85..407b5cd79 100644 --- a/packages/types/src/dashboard.d.ts +++ b/packages/types/src/dashboard.d.ts @@ -24,21 +24,21 @@ export type TDurationFilterOptions = // widget filters export type TAssignedIssuesWidgetFilters = { - target_date?: TDurationFilterOptions; + duration?: TDurationFilterOptions; tab?: TIssuesListTypes; }; export type TCreatedIssuesWidgetFilters = { - target_date?: TDurationFilterOptions; + duration?: TDurationFilterOptions; tab?: TIssuesListTypes; }; export type TIssuesByStateGroupsWidgetFilters = { - target_date?: TDurationFilterOptions; + duration?: TDurationFilterOptions; }; export type TIssuesByPriorityWidgetFilters = { - target_date?: TDurationFilterOptions; + duration?: TDurationFilterOptions; }; export type TWidgetFiltersFormData = diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index c54943f90..1f4a35dd4 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -221,3 +221,12 @@ export interface IGroupByColumn { export interface IIssueMap { [key: string]: TIssue; } + +export interface IIssueListRow { + id: string; + groupId: string; + type: "HEADER" | "NO_ISSUES" | "QUICK_ADD" | "ISSUE"; + name?: string; + icon?: ReactElement | undefined; + payload?: Partial; +} diff --git a/packages/ui/src/breadcrumbs/breadcrumbs.tsx b/packages/ui/src/breadcrumbs/breadcrumbs.tsx index 0f09764ac..a2ae1d680 100644 --- a/packages/ui/src/breadcrumbs/breadcrumbs.tsx +++ b/packages/ui/src/breadcrumbs/breadcrumbs.tsx @@ -10,7 +10,7 @@ type BreadcrumbsProps = { const Breadcrumbs = ({ children }: BreadcrumbsProps) => (
{React.Children.map(children, (child, index) => ( -
+
{child} {index !== React.Children.count(children) - 1 && (
)} /> -
+
{isSmtpConfigured ? ( captureEvent(FORGOT_PASSWORD)} href={`/accounts/forgot-password?email=${email}`} className="text-xs font-medium text-custom-primary-100" > diff --git a/web/components/account/sign-in-forms/root.tsx b/web/components/account/sign-in-forms/root.tsx index c92cd4bd4..62f63caea 100644 --- a/web/components/account/sign-in-forms/root.tsx +++ b/web/components/account/sign-in-forms/root.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import Link from "next/link"; import { observer } from "mobx-react-lite"; // hooks -import { useApplication } from "hooks/store"; +import { useApplication, useEventTracker } from "hooks/store"; import useSignInRedirection from "hooks/use-sign-in-redirection"; // components import { LatestFeatureBlock } from "components/common"; @@ -13,6 +13,8 @@ import { OAuthOptions, SignInOptionalSetPasswordForm, } from "components/account"; +// constants +import { NAVIGATE_TO_SIGNUP } from "constants/event-tracker"; export enum ESignInSteps { EMAIL = "EMAIL", @@ -32,6 +34,7 @@ export const SignInRoot = observer(() => { const { config: { envConfig }, } = useApplication(); + const { captureEvent } = useEventTracker(); // derived values const isSmtpConfigured = envConfig?.is_smtp_configured; @@ -110,7 +113,11 @@ export const SignInRoot = observer(() => {

Don{"'"}t have an account?{" "} - + captureEvent(NAVIGATE_TO_SIGNUP, {})} + className="text-custom-primary-100 font-medium underline" + > Sign up

diff --git a/web/components/account/sign-in-forms/unique-code.tsx b/web/components/account/sign-in-forms/unique-code.tsx index 6e0ae3745..55dbe86e2 100644 --- a/web/components/account/sign-in-forms/unique-code.tsx +++ b/web/components/account/sign-in-forms/unique-code.tsx @@ -7,12 +7,15 @@ import { UserService } from "services/user.service"; // hooks import useToast from "hooks/use-toast"; import useTimer from "hooks/use-timer"; +import { useEventTracker } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // types import { IEmailCheckData, IMagicSignInData } from "@plane/types"; +// constants +import { CODE_VERIFIED } from "constants/event-tracker"; type Props = { email: string; @@ -41,6 +44,8 @@ export const SignInUniqueCodeForm: React.FC = (props) => { const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); // toast alert const { setToastAlert } = useToast(); + // store hooks + const { captureEvent } = useEventTracker(); // timer const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30); // form info @@ -69,17 +74,22 @@ export const SignInUniqueCodeForm: React.FC = (props) => { await authService .magicSignIn(payload) .then(async () => { + captureEvent(CODE_VERIFIED, { + state: "SUCCESS", + }); const currentUser = await userService.currentUser(); - await onSubmit(currentUser.is_password_autoset); }) - .catch((err) => + .catch((err) => { + captureEvent(CODE_VERIFIED, { + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; const handleSendNewCode = async (formData: TUniqueCodeFormValues) => { diff --git a/web/components/account/sign-up-forms/optional-set-password.tsx b/web/components/account/sign-up-forms/optional-set-password.tsx index db14f0ccb..b49adabbb 100644 --- a/web/components/account/sign-up-forms/optional-set-password.tsx +++ b/web/components/account/sign-up-forms/optional-set-password.tsx @@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form"; import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; +import { useEventTracker } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // constants import { ESignUpSteps } from "components/account"; +import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED, SETUP_PASSWORD } from "constants/event-tracker"; // icons import { Eye, EyeOff } from "lucide-react"; @@ -37,6 +39,8 @@ export const SignUpOptionalSetPasswordForm: React.FC = (props) => { // states const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); const [showPassword, setShowPassword] = useState(false); + // store hooks + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); // form info @@ -66,21 +70,34 @@ export const SignUpOptionalSetPasswordForm: React.FC = (props) => { title: "Success!", message: "Password created successfully.", }); + captureEvent(SETUP_PASSWORD, { + state: "SUCCESS", + first_time: true, + }); await handleSignInRedirection(); }) - .catch((err) => + .catch((err) => { + captureEvent(SETUP_PASSWORD, { + state: "FAILED", + first_time: true, + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; const handleGoToWorkspace = async () => { setIsGoingToWorkspace(true); - - await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false)); + await handleSignInRedirection().finally(() => { + captureEvent(PASSWORD_CREATE_SKIPPED, { + state: "SUCCESS", + first_time: true, + }); + setIsGoingToWorkspace(false); + }); }; return ( diff --git a/web/components/account/sign-up-forms/root.tsx b/web/components/account/sign-up-forms/root.tsx index da9d7d79a..8eeb5e99f 100644 --- a/web/components/account/sign-up-forms/root.tsx +++ b/web/components/account/sign-up-forms/root.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useApplication } from "hooks/store"; +import { useApplication, useEventTracker } from "hooks/store"; import useSignInRedirection from "hooks/use-sign-in-redirection"; // components import { @@ -12,6 +12,8 @@ import { SignUpUniqueCodeForm, } from "components/account"; import Link from "next/link"; +// constants +import { NAVIGATE_TO_SIGNIN } from "constants/event-tracker"; export enum ESignUpSteps { EMAIL = "EMAIL", @@ -32,6 +34,7 @@ export const SignUpRoot = observer(() => { const { config: { envConfig }, } = useApplication(); + const { captureEvent } = useEventTracker(); // step 1 submit handler- email verification const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE); @@ -86,7 +89,11 @@ export const SignUpRoot = observer(() => {

Already using Plane?{" "} - + captureEvent(NAVIGATE_TO_SIGNIN, {})} + className="text-custom-primary-100 font-medium underline" + > Sign in

diff --git a/web/components/account/sign-up-forms/unique-code.tsx b/web/components/account/sign-up-forms/unique-code.tsx index 7764b627e..1b54ef9eb 100644 --- a/web/components/account/sign-up-forms/unique-code.tsx +++ b/web/components/account/sign-up-forms/unique-code.tsx @@ -8,12 +8,15 @@ import { UserService } from "services/user.service"; // hooks import useToast from "hooks/use-toast"; import useTimer from "hooks/use-timer"; +import { useEventTracker } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // types import { IEmailCheckData, IMagicSignInData } from "@plane/types"; +// constants +import { CODE_VERIFIED } from "constants/event-tracker"; type Props = { email: string; @@ -39,6 +42,8 @@ export const SignUpUniqueCodeForm: React.FC = (props) => { const { email, handleEmailClear, onSubmit } = props; // states const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); + // store hooks + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); // timer @@ -69,17 +74,22 @@ export const SignUpUniqueCodeForm: React.FC = (props) => { await authService .magicSignIn(payload) .then(async () => { + captureEvent(CODE_VERIFIED, { + state: "SUCCESS", + }); const currentUser = await userService.currentUser(); - await onSubmit(currentUser.is_password_autoset); }) - .catch((err) => + .catch((err) => { + captureEvent(CODE_VERIFIED, { + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; const handleSendNewCode = async (formData: TUniqueCodeFormValues) => { @@ -96,7 +106,6 @@ export const SignUpUniqueCodeForm: React.FC = (props) => { title: "Success!", message: "A new unique code has been sent to your email.", }); - reset({ email: formData.email, token: "", diff --git a/web/components/analytics/custom-analytics/custom-analytics.tsx b/web/components/analytics/custom-analytics/custom-analytics.tsx index a3c083b02..0c3ec8925 100644 --- a/web/components/analytics/custom-analytics/custom-analytics.tsx +++ b/web/components/analytics/custom-analytics/custom-analytics.tsx @@ -10,6 +10,8 @@ import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSi import { IAnalyticsParams } from "@plane/types"; // fetch-keys import { ANALYTICS } from "constants/fetch-keys"; +import { cn } from "helpers/common.helper"; +import { useApplication } from "hooks/store"; type Props = { additionalParams?: Partial; @@ -46,11 +48,13 @@ export const CustomAnalytics: React.FC = observer((props) => { workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null ); + const { theme: themeStore } = useApplication(); + const isProjectLevel = projectId ? true : false; return ( -
-
+
+
= observer((props) => {
- + +
+ +
); }); diff --git a/web/components/analytics/custom-analytics/select-bar.tsx b/web/components/analytics/custom-analytics/select-bar.tsx index 19f83e40b..31acb8471 100644 --- a/web/components/analytics/custom-analytics/select-bar.tsx +++ b/web/components/analytics/custom-analytics/select-bar.tsx @@ -22,9 +22,8 @@ export const CustomAnalyticsSelectBar: React.FC = observer((props) => { return (
{!isProjectLevel && (
diff --git a/web/components/analytics/custom-analytics/sidebar/projects-list.tsx b/web/components/analytics/custom-analytics/sidebar/projects-list.tsx index d09e8def4..f7ba07b75 100644 --- a/web/components/analytics/custom-analytics/sidebar/projects-list.tsx +++ b/web/components/analytics/custom-analytics/sidebar/projects-list.tsx @@ -17,9 +17,9 @@ export const CustomAnalyticsSidebarProjectsList: React.FC = observer((pro const { getProjectById } = useProject(); return ( -
+

Selected Projects

-
+
{projectIds.map((projectId) => { const project = getProjectById(projectId); diff --git a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx index 4a18011d1..ee677fe91 100644 --- a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx +++ b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx @@ -26,7 +26,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => { <> {projectId ? ( cycleDetails ? ( -
+

Analytics for {cycleDetails.name}

@@ -52,7 +52,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
) : moduleDetails ? ( -
+

Analytics for {moduleDetails.name}

@@ -78,7 +78,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
) : ( -
+
{projectDetails?.emoji ? (
{renderEmoji(projectDetails.emoji)}
diff --git a/web/components/analytics/custom-analytics/sidebar/sidebar.tsx b/web/components/analytics/custom-analytics/sidebar/sidebar.tsx index 59013a3e3..c2e12dc3c 100644 --- a/web/components/analytics/custom-analytics/sidebar/sidebar.tsx +++ b/web/components/analytics/custom-analytics/sidebar/sidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { mutate } from "swr"; @@ -19,18 +19,18 @@ import { renderFormattedDate } from "helpers/date-time.helper"; import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types"; // fetch-keys import { ANALYTICS } from "constants/fetch-keys"; +import { cn } from "helpers/common.helper"; type Props = { analytics: IAnalyticsResponse | undefined; params: IAnalyticsParams; - fullScreen: boolean; isProjectLevel: boolean; }; const analyticsService = new AnalyticsService(); export const CustomAnalyticsSidebar: React.FC = observer((props) => { - const { analytics, params, fullScreen, isProjectLevel = false } = props; + const { analytics, params, isProjectLevel = false } = props; // router const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; @@ -138,18 +138,14 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds; + return ( -
- {analytics ? analytics.total : "..."} Issues + {analytics ? analytics.total : "..."}
Issues
{isProjectLevel && (
@@ -158,36 +154,36 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { (cycleId ? cycleDetails?.created_at : moduleId - ? moduleDetails?.created_at - : projectDetails?.created_at) ?? "" + ? moduleDetails?.created_at + : projectDetails?.created_at) ?? "" )}
)}
-
- {fullScreen ? ( - <> - {!isProjectLevel && selectedProjects && selectedProjects.length > 0 && ( - - )} - - - ) : null} + +
+ <> + {!isProjectLevel && selectedProjects && selectedProjects.length > 0 && ( + + )} + +
-
+ +
diff --git a/web/components/analytics/project-modal/main-content.tsx b/web/components/analytics/project-modal/main-content.tsx index 09423e6dd..a04a43260 100644 --- a/web/components/analytics/project-modal/main-content.tsx +++ b/web/components/analytics/project-modal/main-content.tsx @@ -20,16 +20,15 @@ export const ProjectAnalyticsModalMainContent: React.FC = observer((props return ( - + {ANALYTICS_TABS.map((tab) => ( - `rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${ - selected ? "bg-custom-background-80" : "" + `rounded-0 w-full md:w-max md:rounded-3xl border-b md:border border-custom-border-200 focus:outline-none px-0 md:px-4 py-2 text-xs hover:bg-custom-background-80 ${selected ? "border-custom-primary-100 text-custom-primary-100 md:bg-custom-background-80 md:text-custom-text-200 md:border-custom-border-200" : "border-transparent" }` } - onClick={() => {}} + onClick={() => { }} > {tab.title} diff --git a/web/components/core/render-if-visible-HOC.tsx b/web/components/core/render-if-visible-HOC.tsx new file mode 100644 index 000000000..26ae15285 --- /dev/null +++ b/web/components/core/render-if-visible-HOC.tsx @@ -0,0 +1,80 @@ +import { cn } from "helpers/common.helper"; +import React, { useState, useRef, useEffect, ReactNode, MutableRefObject } from "react"; + +type Props = { + defaultHeight?: string; + verticalOffset?: number; + horizonatlOffset?: number; + root?: MutableRefObject; + children: ReactNode; + as?: keyof JSX.IntrinsicElements; + classNames?: string; + alwaysRender?: boolean; + placeholderChildren?: ReactNode; + pauseHeightUpdateWhileRendering?: boolean; + changingReference?: any; +}; + +const RenderIfVisible: React.FC = (props) => { + const { + defaultHeight = "300px", + root, + verticalOffset = 50, + horizonatlOffset = 0, + as = "div", + children, + classNames = "", + alwaysRender = false, //render the children even if it is not visble in root + placeholderChildren = null, //placeholder children + pauseHeightUpdateWhileRendering = false, //while this is true the height of the blocks are maintained + changingReference, //This is to force render when this reference is changed + } = props; + const [shouldVisible, setShouldVisible] = useState(alwaysRender); + const placeholderHeight = useRef(defaultHeight); + const intersectionRef = useRef(null); + + const isVisible = alwaysRender || shouldVisible; + + // Set visibility with intersection observer + useEffect(() => { + if (intersectionRef.current) { + const observer = new IntersectionObserver( + (entries) => { + if (typeof window !== undefined && window.requestIdleCallback) { + window.requestIdleCallback(() => setShouldVisible(entries[0].isIntersecting), { + timeout: 300, + }); + } else { + setShouldVisible(entries[0].isIntersecting); + } + }, + { + root: root?.current, + rootMargin: `${verticalOffset}% ${horizonatlOffset}% ${verticalOffset}% ${horizonatlOffset}%`, + } + ); + observer.observe(intersectionRef.current); + return () => { + if (intersectionRef.current) { + observer.unobserve(intersectionRef.current); + } + }; + } + }, [root?.current, intersectionRef, children, changingReference]); + + //Set height after render + useEffect(() => { + if (intersectionRef.current && isVisible) { + placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`; + } + }, [isVisible, intersectionRef, alwaysRender, pauseHeightUpdateWhileRendering]); + + const child = isVisible ? <>{children} : placeholderChildren; + const style = + isVisible && !pauseHeightUpdateWhileRendering ? {} : { height: placeholderHeight.current, width: "100%" }; + const className = isVisible ? classNames : cn(classNames, "bg-custom-background-80"); + + return React.createElement(as, { ref: intersectionRef, style, className }, child); +}; + +export default RenderIfVisible; diff --git a/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx index 0e34eac2c..fe7b8d177 100644 --- a/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx +++ b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx @@ -3,7 +3,7 @@ import { Menu } from "lucide-react"; import { useApplication } from "hooks/store"; import { observer } from "mobx-react"; -export const SidebarHamburgerToggle: FC = observer (() => { +export const SidebarHamburgerToggle: FC = observer(() => { const { theme: themStore } = useApplication(); return (
= (props) => { // router const router = useRouter(); // store - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); @@ -90,39 +91,55 @@ export const CyclesBoardCard: FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", + addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) + .then(() => { + captureEvent(CYCLE_FAVORITED, { + cycle_id: cycleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); }); - }); }; const handleRemoveFromFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", + removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) + .then(() => { + captureEvent(CYCLE_UNFAVORITED, { + cycle_id: cycleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); }); - }); }; const handleEditCycle = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setTrackElement("Cycles page board layout"); + setTrackElement("Cycles page grid layout"); setUpdateModal(true); }; const handleDeleteCycle = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setTrackElement("Cycles page board layout"); + setTrackElement("Cycles page grid layout"); setDeleteModal(true); }; diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/cycles-list-item.tsx index 725480241..98392cd0e 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/cycles-list-item.tsx @@ -18,6 +18,7 @@ import { CYCLE_STATUS } from "constants/cycle"; import { EUserWorkspaceRoles } from "constants/workspace"; // types import { TCycleGroups } from "@plane/types"; +import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; type TCyclesListItem = { cycleId: string; @@ -37,7 +38,7 @@ export const CyclesListItem: FC = (props) => { // router const router = useRouter(); // store hooks - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); @@ -63,26 +64,42 @@ export const CyclesListItem: FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", + addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) + .then(() => { + captureEvent(CYCLE_FAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); }); - }); }; const handleRemoveFromFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", + removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) + .then(() => { + captureEvent(CYCLE_UNFAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); }); - }); }; const handleEditCycle = (e: MouseEvent) => { @@ -159,9 +176,9 @@ export const CyclesListItem: FC = (props) => { projectId={projectId} /> -
-
-
+
+
+
{isCompleted ? ( @@ -181,20 +198,20 @@ export const CyclesListItem: FC = (props) => {
- + {cycleDetails.name}
-
{currentCycle && (
= (props) => {
)}
-
+
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
-
+
{cycleDetails.assignees.length > 0 ? ( diff --git a/web/components/cycles/delete-modal.tsx b/web/components/cycles/delete-modal.tsx index 32e067833..5dc0306ab 100644 --- a/web/components/cycles/delete-modal.tsx +++ b/web/components/cycles/delete-modal.tsx @@ -10,6 +10,8 @@ import useToast from "hooks/use-toast"; import { Button } from "@plane/ui"; // types import { ICycle } from "@plane/types"; +// constants +import { CYCLE_DELETED } from "constants/event-tracker"; interface ICycleDelete { cycle: ICycle; @@ -45,13 +47,13 @@ export const CycleDeleteModal: React.FC = observer((props) => { message: "Cycle deleted successfully.", }); captureCycleEvent({ - eventName: "Cycle deleted", + eventName: CYCLE_DELETED, payload: { ...cycle, state: "SUCCESS" }, }); }) .catch(() => { captureCycleEvent({ - eventName: "Cycle deleted", + eventName: CYCLE_DELETED, payload: { ...cycle, state: "FAILED" }, }); }); diff --git a/web/components/cycles/form.tsx b/web/components/cycles/form.tsx index 865cc68a1..dfe2a878e 100644 --- a/web/components/cycles/form.tsx +++ b/web/components/cycles/form.tsx @@ -10,7 +10,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { ICycle } from "@plane/types"; type Props = { - handleFormSubmit: (values: Partial) => Promise; + handleFormSubmit: (values: Partial, dirtyFields: any) => Promise; handleClose: () => void; status: boolean; projectId: string; @@ -29,7 +29,7 @@ export const CycleForm: React.FC = (props) => { const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props; // form data const { - formState: { errors, isSubmitting }, + formState: { errors, isSubmitting, dirtyFields }, handleSubmit, control, watch, @@ -61,7 +61,7 @@ export const CycleForm: React.FC = (props) => { maxDate?.setDate(maxDate.getDate() - 1); return ( -
+ handleFormSubmit(formData,dirtyFields))}>
{!status && ( diff --git a/web/components/cycles/modal.tsx b/web/components/cycles/modal.tsx index 7e17e55f1..e8f19d6a1 100644 --- a/web/components/cycles/modal.tsx +++ b/web/components/cycles/modal.tsx @@ -10,6 +10,8 @@ import useLocalStorage from "hooks/use-local-storage"; import { CycleForm } from "components/cycles"; // types import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types"; +// constants +import { CYCLE_CREATED, CYCLE_UPDATED } from "constants/event-tracker"; type CycleModalProps = { isOpen: boolean; @@ -47,7 +49,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { message: "Cycle created successfully.", }); captureCycleEvent({ - eventName: "Cycle created", + eventName: CYCLE_CREATED, payload: { ...res, state: "SUCCESS" }, }); }) @@ -58,18 +60,23 @@ export const CycleCreateUpdateModal: React.FC = (props) => { message: err.detail ?? "Error in creating cycle. Please try again.", }); captureCycleEvent({ - eventName: "Cycle created", + eventName: CYCLE_CREATED, payload: { ...payload, state: "FAILED" }, }); }); }; - const handleUpdateCycle = async (cycleId: string, payload: Partial) => { + const handleUpdateCycle = async (cycleId: string, payload: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId) return; const selectedProjectId = payload.project ?? projectId.toString(); await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload) - .then(() => { + .then((res) => { + const changed_properties = Object.keys(dirtyFields); + captureCycleEvent({ + eventName: CYCLE_UPDATED, + payload: { ...res, changed_properties: changed_properties, state: "SUCCESS" }, + }); setToastAlert({ type: "success", title: "Success!", @@ -77,6 +84,10 @@ export const CycleCreateUpdateModal: React.FC = (props) => { }); }) .catch((err) => { + captureCycleEvent({ + eventName: CYCLE_UPDATED, + payload: { ...payload, state: "FAILED" }, + }); setToastAlert({ type: "error", title: "Error!", @@ -95,7 +106,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { return status; }; - const handleFormSubmit = async (formData: Partial) => { + const handleFormSubmit = async (formData: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId) return; const payload: Partial = { @@ -119,7 +130,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { } if (isDateValid) { - if (data) await handleUpdateCycle(data.id, payload); + if (data) await handleUpdateCycle(data.id, payload, dirtyFields); else { await handleCreateCycle(payload).then(() => { setCycleTab("all"); diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index 299c71008..27182247b 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { useForm } from "react-hook-form"; import { Disclosure, Popover, Transition } from "@headlessui/react"; +import isEmpty from "lodash/isEmpty"; // services import { CycleService } from "services/cycle.service"; // hooks @@ -38,6 +39,7 @@ import { import { ICycle } from "@plane/types"; // constants import { EUserWorkspaceRoles } from "constants/workspace"; +import { CYCLE_UPDATED } from "constants/event-tracker"; // fetch-keys import { CYCLE_STATUS } from "constants/cycle"; @@ -66,7 +68,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const router = useRouter(); const { workspaceSlug, projectId, peekCycle } = router.query; // store hooks - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureCycleEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); @@ -82,10 +84,32 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { defaultValues, }); - const submitChanges = (data: Partial) => { + const submitChanges = (data: Partial, changedProperty: string) => { if (!workspaceSlug || !projectId || !cycleId) return; - updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data); + updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data) + .then((res) => { + captureCycleEvent({ + eventName: CYCLE_UPDATED, + payload: { + ...res, + changed_properties: [changedProperty], + element: "Right side-peek", + state: "SUCCESS", + }, + }); + }) + + .catch((_) => { + captureCycleEvent({ + eventName: CYCLE_UPDATED, + payload: { + ...data, + element: "Right side-peek", + state: "FAILED", + }, + }); + }); }; const handleCopyText = () => { @@ -145,10 +169,13 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); if (isDateValidForExistingCycle) { - submitChanges({ - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }); + submitChanges( + { + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), + }, + "start_date" + ); setToastAlert({ type: "success", title: "Success!", @@ -173,10 +200,13 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); if (isDateValid) { - submitChanges({ - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }); + submitChanges( + { + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), + }, + "start_date" + ); setToastAlert({ type: "success", title: "Success!", @@ -218,10 +248,13 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); if (isDateValidForExistingCycle) { - submitChanges({ - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }); + submitChanges( + { + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), + }, + "end_date" + ); setToastAlert({ type: "success", title: "Success!", @@ -245,10 +278,13 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); if (isDateValid) { - submitChanges({ - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }); + submitChanges( + { + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), + }, + "end_date" + ); setToastAlert({ type: "success", title: "Success!", @@ -293,7 +329,11 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const isEndValid = new Date(`${cycleDetails?.end_date}`) >= new Date(`${cycleDetails?.start_date}`); const progressPercentage = cycleDetails - ? Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100) + ? isCompleted + ? Math.round( + (cycleDetails.progress_snapshot.completed_issues / cycleDetails.progress_snapshot.total_issues) * 100 + ) + : Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100) : null; if (!cycleDetails) @@ -317,7 +357,14 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); const issueCount = - cycleDetails.total_issues === 0 ? "0 Issue" : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + isCompleted && !isEmpty(cycleDetails.progress_snapshot) + ? cycleDetails.progress_snapshot.total_issues === 0 + ? "0 Issue" + : `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}` + : cycleDetails.total_issues === 0 + ? "0 Issue" + : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + const daysLeft = findHowManyDaysLeft(cycleDetails.end_date); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; @@ -568,49 +615,105 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
- {cycleDetails.distribution?.completion_chart && - cycleDetails.start_date && - cycleDetails.end_date ? ( -
-
-
-
- - Ideal + {isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? ( + <> + {cycleDetails.progress_snapshot.distribution?.completion_chart && + cycleDetails.start_date && + cycleDetails.end_date && ( +
+
+
+
+ + Ideal +
+
+ + Current +
+
+
+
+ +
-
- - Current -
-
-
-
- -
-
+ )} + ) : ( - "" + <> + {cycleDetails.distribution?.completion_chart && + cycleDetails.start_date && + cycleDetails.end_date && ( +
+
+
+
+ + Ideal +
+
+ + Current +
+
+
+
+ +
+
+ )} + )} - {cycleDetails.total_issues > 0 && cycleDetails.distribution && ( -
- -
+ {/* stats */} + {isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? ( + <> + {cycleDetails.progress_snapshot.total_issues > 0 && + cycleDetails.progress_snapshot.distribution && ( +
+ +
+ )} + + ) : ( + <> + {cycleDetails.total_issues > 0 && cycleDetails.distribution && ( +
+ +
+ )} + )}
diff --git a/web/components/dashboard/widgets/assigned-issues.tsx b/web/components/dashboard/widgets/assigned-issues.tsx index e2a54c05f..ed6bac324 100644 --- a/web/components/dashboard/widgets/assigned-issues.tsx +++ b/web/components/dashboard/widgets/assigned-issues.tsx @@ -13,7 +13,7 @@ import { WidgetProps, } from "components/dashboard/widgets"; // helpers -import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper"; +import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper"; // types import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types"; // constants @@ -30,8 +30,8 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); - const selectedTab = widgetDetails?.widget_filters.tab ?? "pending"; - const selectedDurationFilter = widgetDetails?.widget_filters.target_date ?? "none"; + const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? "none"; + const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab); const handleUpdateFilters = async (filters: Partial) => { if (!widgetDetails) return; @@ -43,7 +43,7 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { filters, }); - const filterDates = getCustomDates(filters.target_date ?? selectedDurationFilter); + const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, issue_type: filters.tab ?? selectedTab, @@ -86,19 +86,19 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { // switch to pending tab if target date is changed to none if (val === "none" && selectedTab !== "completed") { - handleUpdateFilters({ target_date: val, tab: "pending" }); + handleUpdateFilters({ duration: val, tab: "pending" }); return; } // switch to upcoming tab if target date is changed to other than none if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { handleUpdateFilters({ - target_date: val, + duration: val, tab: "upcoming", }); return; } - handleUpdateFilters({ target_date: val }); + handleUpdateFilters({ duration: val }); }} />
diff --git a/web/components/dashboard/widgets/created-issues.tsx b/web/components/dashboard/widgets/created-issues.tsx index dcdff6685..4ef5708c8 100644 --- a/web/components/dashboard/widgets/created-issues.tsx +++ b/web/components/dashboard/widgets/created-issues.tsx @@ -13,7 +13,7 @@ import { WidgetProps, } from "components/dashboard/widgets"; // helpers -import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper"; +import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper"; // types import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types"; // constants @@ -30,8 +30,8 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); - const selectedTab = widgetDetails?.widget_filters.tab ?? "pending"; - const selectedDurationFilter = widgetDetails?.widget_filters.target_date ?? "none"; + const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? "none"; + const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab); const handleUpdateFilters = async (filters: Partial) => { if (!widgetDetails) return; @@ -43,7 +43,7 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { filters, }); - const filterDates = getCustomDates(filters.target_date ?? selectedDurationFilter); + const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, issue_type: filters.tab ?? selectedTab, @@ -83,19 +83,19 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { // switch to pending tab if target date is changed to none if (val === "none" && selectedTab !== "completed") { - handleUpdateFilters({ target_date: val, tab: "pending" }); + handleUpdateFilters({ duration: val, tab: "pending" }); return; } // switch to upcoming tab if target date is changed to other than none if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { handleUpdateFilters({ - target_date: val, + duration: val, tab: "upcoming", }); return; } - handleUpdateFilters({ target_date: val }); + handleUpdateFilters({ duration: val }); }} />
diff --git a/web/components/dashboard/widgets/issue-panels/tabs-list.tsx b/web/components/dashboard/widgets/issue-panels/tabs-list.tsx index 9ce00a03c..306c2fdeb 100644 --- a/web/components/dashboard/widgets/issue-panels/tabs-list.tsx +++ b/web/components/dashboard/widgets/issue-panels/tabs-list.tsx @@ -16,42 +16,40 @@ export const TabsList: React.FC = observer((props) => { const { durationFilter, selectedTab } = props; const tabsList = durationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; - const selectedTabIndex = tabsList.findIndex((tab) => tab.key === (selectedTab ?? "pending")); + const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab); return (
{tabsList.map((tab) => ( diff --git a/web/components/dashboard/widgets/issues-by-priority.tsx b/web/components/dashboard/widgets/issues-by-priority.tsx index 97884bccc..91e321b05 100644 --- a/web/components/dashboard/widgets/issues-by-priority.tsx +++ b/web/components/dashboard/widgets/issues-by-priority.tsx @@ -73,8 +73,10 @@ export const IssuesByPriorityWidget: React.FC = observer((props) => const { dashboardId, workspaceSlug } = props; // store hooks const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard(); + // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + const selectedDuration = widgetDetails?.widget_filters.duration ?? "none"; const handleUpdateFilters = async (filters: Partial) => { if (!widgetDetails) return; @@ -84,7 +86,7 @@ export const IssuesByPriorityWidget: React.FC = observer((props) => filters, }); - const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none"); + const filterDates = getCustomDates(filters.duration ?? selectedDuration); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), @@ -92,7 +94,7 @@ export const IssuesByPriorityWidget: React.FC = observer((props) => }; useEffect(() => { - const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none"); + const filterDates = getCustomDates(selectedDuration); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), @@ -139,10 +141,10 @@ export const IssuesByPriorityWidget: React.FC = observer((props) => Assigned by priority handleUpdateFilters({ - target_date: val, + duration: val, }) } /> diff --git a/web/components/dashboard/widgets/issues-by-state-group.tsx b/web/components/dashboard/widgets/issues-by-state-group.tsx index 2f7f6ffae..a0eb6c70f 100644 --- a/web/components/dashboard/widgets/issues-by-state-group.tsx +++ b/web/components/dashboard/widgets/issues-by-state-group.tsx @@ -34,6 +34,7 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + const selectedDuration = widgetDetails?.widget_filters.duration ?? "none"; const handleUpdateFilters = async (filters: Partial) => { if (!widgetDetails) return; @@ -43,7 +44,7 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) filters, }); - const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none"); + const filterDates = getCustomDates(filters.duration ?? selectedDuration); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), @@ -52,7 +53,7 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) // fetch widget stats useEffect(() => { - const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none"); + const filterDates = getCustomDates(selectedDuration); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), @@ -138,10 +139,10 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) Assigned by state handleUpdateFilters({ - target_date: val, + duration: val, }) } /> diff --git a/web/components/headers/pages.tsx b/web/components/headers/pages.tsx index 28116b323..1984971d6 100644 --- a/web/components/headers/pages.tsx +++ b/web/components/headers/pages.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { FileText, Plus } from "lucide-react"; // hooks -import { useApplication, useProject, useUser } from "hooks/store"; +import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; // ui import { Breadcrumbs, Button } from "@plane/ui"; // helpers @@ -25,6 +25,7 @@ export const PagesHeader = observer(() => { membership: { currentProjectRole }, } = useUser(); const { currentProjectDetails } = useProject(); + const { setTrackElement } = useEventTracker(); const canUserCreatePage = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); @@ -64,7 +65,15 @@ export const PagesHeader = observer(() => {
{canUserCreatePage && (
-
diff --git a/web/components/headers/user-profile.tsx b/web/components/headers/user-profile.tsx index d54f73009..30bc5b2a9 100644 --- a/web/components/headers/user-profile.tsx +++ b/web/components/headers/user-profile.tsx @@ -1,18 +1,78 @@ // ui -import { Breadcrumbs } from "@plane/ui"; +import { Breadcrumbs, CustomMenu } from "@plane/ui"; import { BreadcrumbLink } from "components/common"; // components import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { cn } from "helpers/common.helper"; +import { FC } from "react"; +import { useApplication, useUser } from "hooks/store"; +import { ChevronDown, PanelRight } from "lucide-react"; +import { observer } from "mobx-react-lite"; +import { PROFILE_ADMINS_TAB, PROFILE_VIEWER_TAB } from "constants/profile"; +import Link from "next/link"; +import { useRouter } from "next/router"; -export const UserProfileHeader = () => ( -
+type TUserProfileHeader = { + type?: string | undefined +} + +export const UserProfileHeader: FC = observer((props) => { + const { type = undefined } = props + + const router = useRouter(); + const { workspaceSlug, userId } = router.query; + + const AUTHORIZED_ROLES = [20, 15, 10]; + const { + membership: { currentWorkspaceRole }, + } = useUser(); + + if (!currentWorkspaceRole) return null; + + const isAuthorized = AUTHORIZED_ROLES.includes(currentWorkspaceRole); + const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; + + const { theme: themStore } = useApplication(); + + return (
-
+
} /> +
+ + {type} + +
+ } + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + <> + {tabsList.map((tab) => ( + + {tab.label} + + ))} + + +
-
-); +
) +}); + + diff --git a/web/components/headers/workspace-analytics.tsx b/web/components/headers/workspace-analytics.tsx index 4d54dd965..a6ad67f05 100644 --- a/web/components/headers/workspace-analytics.tsx +++ b/web/components/headers/workspace-analytics.tsx @@ -1,13 +1,35 @@ import { useRouter } from "next/router"; -import { ArrowLeft, BarChart2 } from "lucide-react"; +import { BarChart2, PanelRight } from "lucide-react"; // ui import { Breadcrumbs } from "@plane/ui"; // components import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { BreadcrumbLink } from "components/common"; +import { useApplication } from "hooks/store"; +import { observer } from "mobx-react"; +import { cn } from "helpers/common.helper"; +import { useEffect } from "react"; -export const WorkspaceAnalyticsHeader = () => { +export const WorkspaceAnalyticsHeader = observer(() => { const router = useRouter(); + const { analytics_tab } = router.query; + + const { theme: themeStore } = useApplication(); + + useEffect(() => { + const handleToggleWorkspaceAnalyticsSidebar = () => { + if (window && window.innerWidth < 768) { + themeStore.toggleWorkspaceAnalyticsSidebar(true); + } + if (window && themeStore.workspaceAnalyticsSidebarCollapsed && window.innerWidth >= 768) { + themeStore.toggleWorkspaceAnalyticsSidebar(false); + } + }; + + window.addEventListener("resize", handleToggleWorkspaceAnalyticsSidebar); + handleToggleWorkspaceAnalyticsSidebar(); + return () => window.removeEventListener("resize", handleToggleWorkspaceAnalyticsSidebar); + }, [themeStore]); return ( <> @@ -16,7 +38,7 @@ export const WorkspaceAnalyticsHeader = () => { >
-
+
{ } /> + {analytics_tab === 'custom' && + + }
); -}; +}); diff --git a/web/components/headers/workspace-dashboard.tsx b/web/components/headers/workspace-dashboard.tsx index d8306ab40..6b85577f6 100644 --- a/web/components/headers/workspace-dashboard.tsx +++ b/web/components/headers/workspace-dashboard.tsx @@ -4,13 +4,18 @@ import { useTheme } from "next-themes"; // images import githubBlackImage from "/public/logos/github-black.png"; import githubWhiteImage from "/public/logos/github-white.png"; +// hooks +import { useEventTracker } from "hooks/store"; // components import { BreadcrumbLink } from "components/common"; import { Breadcrumbs } from "@plane/ui"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +// constants +import { CHANGELOG_REDIRECTED, GITHUB_REDIRECTED } from "constants/event-tracker"; export const WorkspaceDashboardHeader = () => { // hooks + const { captureEvent } = useEventTracker(); const { resolvedTheme } = useTheme(); return ( @@ -31,16 +36,26 @@ export const WorkspaceDashboardHeader = () => {
diff --git a/web/components/inbox/inbox-issue-actions.tsx b/web/components/inbox/inbox-issue-actions.tsx index 82253af88..998ad268c 100644 --- a/web/components/inbox/inbox-issue-actions.tsx +++ b/web/components/inbox/inbox-issue-actions.tsx @@ -20,6 +20,7 @@ import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle // types import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types"; import { EUserProjectRoles } from "constants/project"; +import { ISSUE_DELETED } from "constants/event-tracker"; type TInboxIssueActionsHeader = { workspaceSlug: string; @@ -86,17 +87,12 @@ export const InboxIssueActionsHeader: FC = observer((p throw new Error("Missing required parameters"); await removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: inboxIssueId, state: "SUCCESS", element: "Inbox page", - }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, + } }); router.push({ pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, @@ -108,17 +104,12 @@ export const InboxIssueActionsHeader: FC = observer((p message: "Something went wrong while deleting inbox issue. Please try again.", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: inboxIssueId, state: "FAILED", element: "Inbox page", }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); } }, diff --git a/web/components/inbox/modals/create-issue-modal.tsx b/web/components/inbox/modals/create-issue-modal.tsx index 066f172ca..84c4bef1e 100644 --- a/web/components/inbox/modals/create-issue-modal.tsx +++ b/web/components/inbox/modals/create-issue-modal.tsx @@ -18,6 +18,8 @@ import { GptAssistantPopover } from "components/core"; import { Button, Input, ToggleSwitch } from "@plane/ui"; // types import { TIssue } from "@plane/types"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; type Props = { isOpen: boolean; @@ -65,7 +67,6 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { config: { envConfig }, } = useApplication(); const { captureIssueEvent } = useEventTracker(); - const { currentWorkspace } = useWorkspace(); const { control, @@ -94,34 +95,24 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { handleClose(); } else reset(defaultValues); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...formData, state: "SUCCESS", element: "Inbox page", }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, path: router.pathname, }); }) .catch((error) => { console.error(error); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...formData, state: "FAILED", element: "Inbox page", }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, path: router.pathname, }); }); diff --git a/web/components/issues/attachment/root.tsx b/web/components/issues/attachment/root.tsx index 11d74af0e..ffa17d337 100644 --- a/web/components/issues/attachment/root.tsx +++ b/web/components/issues/attachment/root.tsx @@ -38,7 +38,7 @@ export const IssueAttachmentRoot: FC = (props) => { title: "Attachment uploaded", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: "Issue attachment added", payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "attachment", @@ -47,7 +47,7 @@ export const IssueAttachmentRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: "Issue attachment added", payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, }); setToastAlert({ @@ -67,7 +67,7 @@ export const IssueAttachmentRoot: FC = (props) => { title: "Attachment removed", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: "Issue attachment deleted", payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "attachment", @@ -76,7 +76,7 @@ export const IssueAttachmentRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: "Issue attachment deleted", payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "attachment", diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx index 2e0303a8e..92badf4b2 100644 --- a/web/components/issues/issue-detail/root.tsx +++ b/web/components/issues/issue-detail/root.tsx @@ -16,6 +16,7 @@ import { TIssue } from "@plane/types"; // constants import { EUserProjectRoles } from "constants/project"; import { EIssuesStoreType } from "constants/issue"; +import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker"; export type TIssueOperations = { fetch: (workspaceSlug: string, projectId: string, issueId: string) => Promise; @@ -102,7 +103,7 @@ export const IssueDetailRoot: FC = (props) => { }); } captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: Object.keys(data).join(","), @@ -112,7 +113,7 @@ export const IssueDetailRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue detail page" }, updates: { changed_property: Object.keys(data).join(","), @@ -138,7 +139,7 @@ export const IssueDetailRoot: FC = (props) => { message: "Issue deleted successfully", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, path: router.asPath, }); @@ -149,7 +150,7 @@ export const IssueDetailRoot: FC = (props) => { message: "Issue delete failed", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, path: router.asPath, }); @@ -164,7 +165,7 @@ export const IssueDetailRoot: FC = (props) => { message: "Issue added to issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "cycle_id", @@ -174,7 +175,7 @@ export const IssueDetailRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "cycle_id", @@ -198,7 +199,7 @@ export const IssueDetailRoot: FC = (props) => { message: "Cycle removed from issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "cycle_id", @@ -208,7 +209,7 @@ export const IssueDetailRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "cycle_id", @@ -232,7 +233,7 @@ export const IssueDetailRoot: FC = (props) => { message: "Module added to issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "module_id", @@ -242,7 +243,7 @@ export const IssueDetailRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "module_id", @@ -266,7 +267,7 @@ export const IssueDetailRoot: FC = (props) => { message: "Module removed from issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "module_id", @@ -276,7 +277,7 @@ export const IssueDetailRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "module_id", diff --git a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx index 1f62c248c..6db9323fa 100644 --- a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx @@ -13,6 +13,8 @@ import { createIssuePayload } from "helpers/issue.helper"; import { PlusIcon } from "lucide-react"; // types import { TIssue } from "@plane/types"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; type Props = { formKey: keyof TIssue; @@ -129,7 +131,7 @@ export const CalendarQuickAddIssueForm: React.FC = observer((props) => { viewId ).then((res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "Calendar quick add" }, path: router.asPath, }); @@ -142,7 +144,7 @@ export const CalendarQuickAddIssueForm: React.FC = observer((props) => { } catch (err: any) { console.error(err); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "Calendar quick add" }, path: router.asPath, }); diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx index 0dae3c8bd..c03e86504 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import isEqual from "lodash/isEqual"; // hooks -import { useGlobalView, useIssues, useLabel, useUser } from "hooks/store"; +import { useEventTracker, useGlobalView, useIssues, useLabel, useUser } from "hooks/store"; //ui import { Button } from "@plane/ui"; // components @@ -11,6 +11,8 @@ import { AppliedFiltersList } from "components/issues"; import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types"; import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "constants/workspace"; +// constants +import { GLOBAL_VIEW_UPDATED } from "constants/event-tracker"; type Props = { globalViewId: string; @@ -27,6 +29,7 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => { } = useIssues(EIssuesStoreType.GLOBAL); const { workspaceLabels } = useLabel(); const { globalViewMap, updateGlobalView } = useGlobalView(); + const { captureEvent } = useEventTracker(); const { membership: { currentWorkspaceRole }, } = useUser(); @@ -91,6 +94,13 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => { filters: { ...(appliedFilters ?? {}), }, + }).then((res) => { + captureEvent(GLOBAL_VIEW_UPDATED, { + view_id: res.id, + applied_filters: res.filters, + state: "SUCCESS", + element: "Spreadsheet view", + }); }); }; diff --git a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx index e89f60688..bfecb993b 100644 --- a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx @@ -13,6 +13,8 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { createIssuePayload } from "helpers/issue.helper"; // types import { IProject, TIssue } from "@plane/types"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; interface IInputProps { formKey: string; @@ -111,7 +113,7 @@ export const GanttQuickAddIssueForm: React.FC = observe quickAddCallback && (await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId).then((res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "Gantt quick add" }, path: router.asPath, }); @@ -123,7 +125,7 @@ export const GanttQuickAddIssueForm: React.FC = observe }); } catch (err: any) { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "Gantt quick add" }, path: router.asPath, }); diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 64b132267..83f72d8ea 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useState } from "react"; +import { FC, useCallback, useRef, useState } from "react"; import { DragDropContext, DragStart, DraggableLocation, DropResult, Droppable } from "@hello-pangea/dnd"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; @@ -25,6 +25,7 @@ import { IProfileIssues, IProfileIssuesFilter } from "store/issue/profile"; import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module"; import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; import { EIssueFilterType, TCreateModalStoreTypes } from "constants/issue"; +import { ISSUE_DELETED } from "constants/event-tracker"; export interface IBaseKanBanLayout { issues: IProjectIssues | ICycleIssues | IDraftIssues | IModuleIssues | IProjectViewIssues | IProfileIssues; @@ -94,6 +95,8 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {}; + const scrollableContainerRef = useRef(null); + // states const [isDragStarted, setIsDragStarted] = useState(false); const [dragState, setDragState] = useState({}); @@ -210,7 +213,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas setDeleteIssueModal(false); setDragState({}); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: dragState.draggedIssueId!, state: "FAILED", element: "Kanban layout drag & drop" }, path: router.asPath, }); @@ -245,7 +248,10 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas
)} -
+
{/* drag and delete component */} @@ -289,6 +295,8 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas canEditProperties={canEditProperties} storeType={storeType} addIssuesToView={addIssuesToView} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} />
diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 203ac4938..24cbe9908 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { MutableRefObject, memo } from "react"; import { Draggable, DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; import { observer } from "mobx-react-lite"; // hooks @@ -13,6 +13,7 @@ import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; import { EIssueActions } from "../types"; // helper import { cn } from "helpers/common.helper"; +import RenderIfVisible from "components/core/render-if-visible-HOC"; interface IssueBlockProps { peekIssueId?: string; @@ -25,6 +26,9 @@ interface IssueBlockProps { handleIssues: (issue: TIssue, action: EIssueActions) => void; quickActions: (issue: TIssue) => React.ReactNode; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; + issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization } interface IssueDetailsBlockProps { @@ -107,6 +111,9 @@ export const KanbanIssueBlock: React.FC = memo((props) => { handleIssues, quickActions, canEditProperties, + scrollableContainerRef, + isDragStarted, + issueIds, } = props; const issue = issuesMap[issueId]; @@ -129,24 +136,31 @@ export const KanbanIssueBlock: React.FC = memo((props) => { {...provided.dragHandleProps} ref={provided.innerRef} > - {issue.tempId !== undefined && ( -
- )}
- + + +
)} diff --git a/web/components/issues/issue-layouts/kanban/blocks-list.tsx b/web/components/issues/issue-layouts/kanban/blocks-list.tsx index 15c797833..3746111e5 100644 --- a/web/components/issues/issue-layouts/kanban/blocks-list.tsx +++ b/web/components/issues/issue-layouts/kanban/blocks-list.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { MutableRefObject, memo } from "react"; //types import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; import { EIssueActions } from "../types"; @@ -16,6 +16,8 @@ interface IssueBlocksListProps { handleIssues: (issue: TIssue, action: EIssueActions) => void; quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; } const KanbanIssueBlocksListMemo: React.FC = (props) => { @@ -30,6 +32,8 @@ const KanbanIssueBlocksListMemo: React.FC = (props) => { handleIssues, quickActions, canEditProperties, + scrollableContainerRef, + isDragStarted, } = props; return ( @@ -56,6 +60,9 @@ const KanbanIssueBlocksListMemo: React.FC = (props) => { index={index} isDragDisabled={isDragDisabled} canEditProperties={canEditProperties} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} + issueIds={issueIds} //passing to force render for virtualization whenever parent rerenders /> ); })} diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index de6c1ddae..f11321944 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -20,6 +20,7 @@ import { import { EIssueActions } from "../types"; import { getGroupByColumns } from "../utils"; import { TCreateModalStoreTypes } from "constants/issue"; +import { MutableRefObject } from "react"; export interface IGroupByKanBan { issuesMap: IIssueMap; @@ -45,6 +46,8 @@ export interface IGroupByKanBan { storeType?: TCreateModalStoreTypes; addIssuesToView?: (issueIds: string[]) => Promise; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; } const GroupByKanBan: React.FC = observer((props) => { @@ -67,6 +70,8 @@ const GroupByKanBan: React.FC = observer((props) => { storeType, addIssuesToView, canEditProperties, + scrollableContainerRef, + isDragStarted, } = props; const member = useMember(); @@ -92,11 +97,7 @@ const GroupByKanBan: React.FC = observer((props) => { const groupByVisibilityToggle = visibilityGroupBy(_list); return ( -
+
{sub_group_by === null && (
= observer((props) => { disableIssueCreation={disableIssueCreation} canEditProperties={canEditProperties} groupByVisibilityToggle={groupByVisibilityToggle} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} /> )}
@@ -168,6 +171,8 @@ export interface IKanBan { storeType?: TCreateModalStoreTypes; addIssuesToView?: (issueIds: string[]) => Promise; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; } export const KanBan: React.FC = observer((props) => { @@ -189,6 +194,8 @@ export const KanBan: React.FC = observer((props) => { storeType, addIssuesToView, canEditProperties, + scrollableContainerRef, + isDragStarted, } = props; const issueKanBanView = useKanbanView(); @@ -213,6 +220,8 @@ export const KanBan: React.FC = observer((props) => { storeType={storeType} addIssuesToView={addIssuesToView} canEditProperties={canEditProperties} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} /> ); }); diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index 1a25c563e..7cbda05e1 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -1,3 +1,4 @@ +import { MutableRefObject } from "react"; import { Droppable } from "@hello-pangea/dnd"; // hooks import { useProjectState } from "hooks/store"; @@ -37,6 +38,8 @@ interface IKanbanGroup { disableIssueCreation?: boolean; canEditProperties: (projectId: string | undefined) => boolean; groupByVisibilityToggle: boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; } export const KanbanGroup = (props: IKanbanGroup) => { @@ -57,6 +60,8 @@ export const KanbanGroup = (props: IKanbanGroup) => { disableIssueCreation, quickAddCallback, viewId, + scrollableContainerRef, + isDragStarted, } = props; // hooks const projectState = useProjectState(); @@ -127,6 +132,8 @@ export const KanbanGroup = (props: IKanbanGroup) => { handleIssues={handleIssues} quickActions={quickActions} canEditProperties={canEditProperties} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} /> {provided.placeholder} diff --git a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx index 8880ca278..513163431 100644 --- a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx @@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector"; import { createIssuePayload } from "helpers/issue.helper"; // types import { TIssue } from "@plane/types"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; const Inputs = (props: any) => { const { register, setFocus, projectDetail } = props; @@ -106,7 +108,7 @@ export const KanBanQuickAddIssueForm: React.FC = obser viewId ).then((res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "Kanban quick add" }, path: router.asPath, }); @@ -118,7 +120,7 @@ export const KanBanQuickAddIssueForm: React.FC = obser }); } catch (err: any) { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "Kanban quick add" }, path: router.asPath, }); diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index 1b9f27828..5fdb58ef0 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -1,3 +1,4 @@ +import { MutableRefObject } from "react"; import { observer } from "mobx-react-lite"; // components import { KanBan } from "./default"; @@ -80,6 +81,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { viewId?: string ) => Promise; viewId?: string; + scrollableContainerRef?: MutableRefObject; } const SubGroupSwimlane: React.FC = observer((props) => { const { @@ -99,6 +101,8 @@ const SubGroupSwimlane: React.FC = observer((props) => { addIssuesToView, quickAddCallback, viewId, + scrollableContainerRef, + isDragStarted, } = props; const calculateIssueCount = (column_id: string) => { @@ -150,6 +154,8 @@ const SubGroupSwimlane: React.FC = observer((props) => { addIssuesToView={addIssuesToView} quickAddCallback={quickAddCallback} viewId={viewId} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} />
)} @@ -183,6 +189,7 @@ export interface IKanBanSwimLanes { ) => Promise; viewId?: string; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; } export const KanBanSwimLanes: React.FC = observer((props) => { @@ -204,6 +211,7 @@ export const KanBanSwimLanes: React.FC = observer((props) => { addIssuesToView, quickAddCallback, viewId, + scrollableContainerRef, } = props; const member = useMember(); @@ -249,6 +257,7 @@ export const KanBanSwimLanes: React.FC = observer((props) => { canEditProperties={canEditProperties} quickAddCallback={quickAddCallback} viewId={viewId} + scrollableContainerRef={scrollableContainerRef} /> )}
diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index 8f661a9e6..b1441cff7 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -122,26 +122,24 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { ); return ( - <> -
- -
- +
+ +
); }); diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 1ade285a9..ceec7b219 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -48,64 +48,59 @@ export const IssueBlock: React.FC = observer((props: IssueBlock const projectDetails = getProjectById(issue.project_id); return ( - <> -
- {displayProperties && displayProperties?.key && ( -
- {projectDetails?.identifier}-{issue.sequence_id} -
- )} + "last:border-b-transparent": peekIssue?.issueId !== issue.id + })} + > + {displayProperties && displayProperties?.key && ( +
+ {projectDetails?.identifier}-{issue.sequence_id} +
+ )} - {issue?.tempId !== undefined && ( -
- )} + {issue?.tempId !== undefined && ( +
+ )} - {issue?.is_draft ? ( + {issue?.is_draft ? ( + + {issue.name} + + ) : ( + handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + > {issue.name} - ) : ( - handleIssuePeekOverview(issue)} - className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" - > - - {issue.name} - - - )} + + )} -
- {!issue?.tempId ? ( - <> - - {quickActions(issue)} - - ) : ( -
- -
- )} -
+
+ {!issue?.tempId ? ( + <> + + {quickActions(issue)} + + ) : ( +
+ +
+ )}
- +
); }); diff --git a/web/components/issues/issue-layouts/list/blocks-list.tsx b/web/components/issues/issue-layouts/list/blocks-list.tsx index 95ee6c7a8..d3c8d1406 100644 --- a/web/components/issues/issue-layouts/list/blocks-list.tsx +++ b/web/components/issues/issue-layouts/list/blocks-list.tsx @@ -1,9 +1,10 @@ -import { FC } from "react"; +import { FC, MutableRefObject } from "react"; // components import { IssueBlock } from "components/issues"; // types import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types"; import { EIssueActions } from "../types"; +import RenderIfVisible from "components/core/render-if-visible-HOC"; interface Props { issueIds: TGroupedIssues | TUnGroupedIssues | any; @@ -12,27 +13,34 @@ interface Props { handleIssues: (issue: TIssue, action: EIssueActions) => Promise; quickActions: (issue: TIssue) => React.ReactNode; displayProperties: IIssueDisplayProperties | undefined; + containerRef: MutableRefObject; } export const IssueBlocksList: FC = (props) => { - const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties } = props; + const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties, containerRef } = props; return (
{issueIds && issueIds.length > 0 ? ( issueIds.map((issueId: string) => { if (!issueId) return null; - return ( - + + + ); }) ) : ( diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index dd6c8da22..373897fda 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -1,5 +1,7 @@ +import { useRef } from "react"; // components import { IssueBlocksList, ListQuickAddIssueForm } from "components/issues"; +import { HeaderGroupByCard } from "./headers/group-by-card"; // hooks import { useLabel, useMember, useProject, useProjectState } from "hooks/store"; // types @@ -10,12 +12,12 @@ import { IIssueDisplayProperties, TIssueMap, TUnGroupedIssues, + IGroupByColumn, } from "@plane/types"; import { EIssueActions } from "../types"; // constants -import { HeaderGroupByCard } from "./headers/group-by-card"; -import { getGroupByColumns } from "../utils"; import { TCreateModalStoreTypes } from "constants/issue"; +import { getGroupByColumns } from "../utils"; export interface IGroupByList { issueIds: TGroupedIssues | TUnGroupedIssues | any; @@ -64,9 +66,11 @@ const GroupByList: React.FC = (props) => { const label = useLabel(); const projectState = useProjectState(); - const list = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true); + const containerRef = useRef(null); - if (!list) return null; + const groups = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true); + + if (!groups) return null; const prePopulateQuickAddData = (groupByKey: string | null, value: any) => { const defaultState = projectState.projectStates?.find((state) => state.default); @@ -104,11 +108,11 @@ const GroupByList: React.FC = (props) => { const isGroupByCreatedBy = group_by === "created_by"; return ( -
- {list && - list.length > 0 && - list.map( - (_list: any) => +
+ {groups && + groups.length > 0 && + groups.map( + (_list: IGroupByColumn) => validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
@@ -131,6 +135,7 @@ const GroupByList: React.FC = (props) => { quickActions={quickActions} displayProperties={displayProperties} canEditProperties={canEditProperties} + containerRef={containerRef} /> )} diff --git a/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx index dd63f09aa..8d1ce6d9c 100644 --- a/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx @@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector"; import { TIssue, IProject } from "@plane/types"; // types import { createIssuePayload } from "helpers/issue.helper"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; interface IInputProps { formKey: string; @@ -103,7 +105,7 @@ export const ListQuickAddIssueForm: FC = observer((props quickAddCallback && (await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId).then((res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "List quick add" }, path: router.asPath, }); @@ -115,7 +117,7 @@ export const ListQuickAddIssueForm: FC = observer((props }); } catch (err: any) { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "List quick add" }, path: router.asPath, }); diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index e0a0dbd5c..4d851545e 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -18,6 +18,8 @@ import { import { renderFormattedPayloadDate } from "helpers/date-time.helper"; // types import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; +// constants +import { ISSUE_UPDATED } from "constants/event-tracker"; export interface IIssueProperties { issue: TIssue; @@ -40,7 +42,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleState = (stateId: string) => { handleIssues({ ...issue, state_id: stateId }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -54,7 +56,7 @@ export const IssueProperties: React.FC = observer((props) => { const handlePriority = (value: TIssuePriorities) => { handleIssues({ ...issue, priority: value }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -68,7 +70,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleLabel = (ids: string[]) => { handleIssues({ ...issue, label_ids: ids }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -82,7 +84,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleAssignee = (ids: string[]) => { handleIssues({ ...issue, assignee_ids: ids }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -96,7 +98,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleStartDate = (date: Date | null) => { handleIssues({ ...issue, start_date: date ? renderFormattedPayloadDate(date) : null }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -110,7 +112,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleTargetDate = (date: Date | null) => { handleIssues({ ...issue, target_date: date ? renderFormattedPayloadDate(date) : null }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -124,7 +126,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleEstimate = (value: number | null) => { handleIssues({ ...issue, estimate_point: value }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx index c5674cee9..98262b504 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx @@ -21,6 +21,7 @@ export const SpreadsheetDueDateColumn: React.FC = observer((props: Props)
{ const targetDate = data ? renderFormattedPayloadDate(data) : null; onChange( diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx index fcbd817b6..82c00fc12 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx @@ -21,6 +21,7 @@ export const SpreadsheetStartDateColumn: React.FC = observer((props: Prop
{ const startDate = data ? renderFormattedPayloadDate(data) : null; onChange( diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 2a97045fe..840ea39f9 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { Dispatch, MutableRefObject, SetStateAction, useRef, useState } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // icons @@ -7,6 +7,7 @@ import { ChevronRight, MoreHorizontal } from "lucide-react"; import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; // components import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import RenderIfVisible from "components/core/render-if-visible-HOC"; import { IssueColumn } from "./issue-column"; // ui import { ControlLink, Tooltip } from "@plane/ui"; @@ -32,6 +33,9 @@ interface Props { portalElement: React.MutableRefObject; nestingLevel: number; issueId: string; + isScrolled: MutableRefObject; + containerRef: MutableRefObject; + issueIds: string[]; } export const SpreadsheetIssueRow = observer((props: Props) => { @@ -44,8 +48,96 @@ export const SpreadsheetIssueRow = observer((props: Props) => { handleIssues, quickActions, canEditProperties, + isScrolled, + containerRef, + issueIds, } = props; + const [isExpanded, setExpanded] = useState(false); + const { subIssues: subIssuesStore } = useIssueDetail(); + + const subIssues = subIssuesStore.subIssuesByIssueId(issueId); + + return ( + <> + {/* first column/ issue name and key column */} + } + changingReference={issueIds} + > + + + + {isExpanded && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssueId: string) => ( + + ))} + + ); +}); + +interface IssueRowDetailsProps { + displayProperties: IIssueDisplayProperties; + isEstimateEnabled: boolean; + quickActions: ( + issue: TIssue, + customActionButton?: React.ReactElement, + portalElement?: HTMLDivElement | null + ) => React.ReactNode; + canEditProperties: (projectId: string | undefined) => boolean; + handleIssues: (issue: TIssue, action: EIssueActions) => Promise; + portalElement: React.MutableRefObject; + nestingLevel: number; + issueId: string; + isScrolled: MutableRefObject; + isExpanded: boolean; + setExpanded: Dispatch>; +} + +const IssueRowDetails = observer((props: IssueRowDetailsProps) => { + const { + displayProperties, + issueId, + isEstimateEnabled, + nestingLevel, + portalElement, + handleIssues, + quickActions, + canEditProperties, + isScrolled, + isExpanded, + setExpanded, + } = props; // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -54,8 +146,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => { const { peekIssue, setPeekIssue } = useIssueDetail(); // states const [isMenuActive, setIsMenuActive] = useState(false); - const [isExpanded, setExpanded] = useState(false); - const menuActionRef = useRef(null); const handleIssuePeekOverview = (issue: TIssue) => { @@ -66,7 +156,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => { const { subIssues: subIssuesStore, issue } = useIssueDetail(); const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); const paddingLeft = `${nestingLevel * 54}px`; @@ -91,81 +180,77 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
); - if (!issueDetail) return null; const disableUserActions = !canEditProperties(issueDetail.project_id); return ( <> - - {/* first column/ issue name and key column */} - - -
-
- - {getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id} - + +
+
+ + {getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id} + - {canEditProperties(issueDetail.project_id) && ( - - )} -
- - {issueDetail.sub_issues_count > 0 && ( -
- + {canEditProperties(issueDetail.project_id) && ( + )}
- - handleIssuePeekOverview(issueDetail)} - className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" - > -
- -
0 && ( +
+
- -
- - - {/* Rest of the columns */} - {SPREADSHEET_PROPERTY_LIST.map((property) => ( + + +
+ )} +
+
+ handleIssuePeekOverview(issueDetail)} + className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + > +
+ +
+ {issueDetail.name} +
+
+
+
+ + {/* Rest of the columns */} + {SPREADSHEET_PROPERTY_LIST.map((property) => ( { isEstimateEnabled={isEstimateEnabled} /> ))} - - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( - - ))} ); }); diff --git a/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx index b0acd7237..3cba3c6cd 100644 --- a/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx @@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector"; import { createIssuePayload } from "helpers/issue.helper"; // types import { TIssue } from "@plane/types"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; type Props = { formKey: keyof TIssue; @@ -162,7 +164,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => (await quickAddCallback(currentWorkspace.slug, currentProjectDetails.id, { ...payload } as TIssue, viewId).then( (res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "Spreadsheet quick add" }, path: router.asPath, }); @@ -175,7 +177,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => }); } catch (err: any) { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "Spreadsheet quick add" }, path: router.asPath, }); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx index e63b01dfb..5d45157cc 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx @@ -1,4 +1,5 @@ import { observer } from "mobx-react-lite"; +import { MutableRefObject, useEffect, useRef } from "react"; //types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types"; import { EIssueActions } from "../types"; @@ -21,6 +22,7 @@ type Props = { handleIssues: (issue: TIssue, action: EIssueActions) => Promise; canEditProperties: (projectId: string | undefined) => boolean; portalElement: React.MutableRefObject; + containerRef: MutableRefObject; }; export const SpreadsheetTable = observer((props: Props) => { @@ -34,8 +36,45 @@ export const SpreadsheetTable = observer((props: Props) => { quickActions, handleIssues, canEditProperties, + containerRef, } = props; + // states + const isScrolled = useRef(false); + + const handleScroll = () => { + if (!containerRef.current) return; + const scrollLeft = containerRef.current.scrollLeft; + + const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns + const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers + + //The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly + if (scrollLeft > 0 !== isScrolled.current) { + const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child"); + + for (let i = 0; i < firtColumns.length; i++) { + const shadow = i === 0 ? headerShadow : columnShadow; + if (scrollLeft > 0) { + (firtColumns[i] as HTMLElement).style.boxShadow = shadow; + } else { + (firtColumns[i] as HTMLElement).style.boxShadow = "none"; + } + } + isScrolled.current = scrollLeft > 0; + } + }; + + useEffect(() => { + const currentContainerRef = containerRef.current; + + if (currentContainerRef) currentContainerRef.addEventListener("scroll", handleScroll); + + return () => { + if (currentContainerRef) currentContainerRef.removeEventListener("scroll", handleScroll); + }; + }, []); + const handleKeyBoardNavigation = useTableKeyboardNavigation(); return ( @@ -58,6 +97,9 @@ export const SpreadsheetTable = observer((props: Props) => { isEstimateEnabled={isEstimateEnabled} handleIssues={handleIssues} portalElement={portalElement} + containerRef={containerRef} + isScrolled={isScrolled} + issueIds={issueIds} /> ))} diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index e99b17850..1ac815ced 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from "react"; +import React, { useRef } from "react"; import { observer } from "mobx-react-lite"; // components import { Spinner } from "@plane/ui"; @@ -48,8 +48,6 @@ export const SpreadsheetView: React.FC = observer((props) => { enableQuickCreateIssue, disableIssueCreation, } = props; - // states - const isScrolled = useRef(false); // refs const containerRef = useRef(null); const portalRef = useRef(null); @@ -58,39 +56,6 @@ export const SpreadsheetView: React.FC = observer((props) => { const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null; - const handleScroll = () => { - if (!containerRef.current) return; - const scrollLeft = containerRef.current.scrollLeft; - - const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns - const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers - - //The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly - if (scrollLeft > 0 !== isScrolled.current) { - const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child"); - - for (let i = 0; i < firtColumns.length; i++) { - const shadow = i === 0 ? headerShadow : columnShadow; - if (scrollLeft > 0) { - (firtColumns[i] as HTMLElement).style.boxShadow = shadow; - } else { - (firtColumns[i] as HTMLElement).style.boxShadow = "none"; - } - } - isScrolled.current = scrollLeft > 0; - } - }; - - useEffect(() => { - const currentContainerRef = containerRef.current; - - if (currentContainerRef) currentContainerRef.addEventListener("scroll", handleScroll); - - return () => { - if (currentContainerRef) currentContainerRef.removeEventListener("scroll", handleScroll); - }; - }, []); - if (!issueIds || issueIds.length === 0) return (
@@ -112,6 +77,7 @@ export const SpreadsheetView: React.FC = observer((props) => { quickActions={quickActions} handleIssues={handleIssues} canEditProperties={canEditProperties} + containerRef={containerRef} />
diff --git a/web/components/issues/issue-layouts/utils.tsx b/web/components/issues/issue-layouts/utils.tsx index 83ec363b9..0c3367dc1 100644 --- a/web/components/issues/issue-layouts/utils.tsx +++ b/web/components/issues/issue-layouts/utils.tsx @@ -1,10 +1,10 @@ import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui"; -import { ISSUE_PRIORITIES } from "constants/issue"; +import { EIssueListRow, ISSUE_PRIORITIES } from "constants/issue"; import { renderEmoji } from "helpers/emoji.helper"; import { IMemberRootStore } from "store/member"; import { IProjectStore } from "store/project/project.store"; import { IStateStore } from "store/state.store"; -import { GroupByColumnTypes, IGroupByColumn } from "@plane/types"; +import { GroupByColumnTypes, IGroupByColumn, IIssueListRow, TGroupedIssues, TUnGroupedIssues } from "@plane/types"; import { STATE_GROUPS } from "constants/state"; import { ILabelStore } from "store/label.store"; diff --git a/web/components/issues/issue-modal/modal.tsx b/web/components/issues/issue-modal/modal.tsx index 02a087314..97d977ace 100644 --- a/web/components/issues/issue-modal/modal.tsx +++ b/web/components/issues/issue-modal/modal.tsx @@ -13,6 +13,8 @@ import { IssueFormRoot } from "./form"; import type { TIssue } from "@plane/types"; // constants import { EIssuesStoreType, TCreateModalStoreTypes } from "constants/issue"; +import { ISSUE_CREATED, ISSUE_UPDATED } from "constants/event-tracker"; + export interface IssuesModalProps { data?: Partial; isOpen: boolean; @@ -157,14 +159,9 @@ export const CreateUpdateIssueModal: React.FC = observer((prop message: "Issue created successfully.", }); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...response, state: "SUCCESS" }, path: router.asPath, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); !createMore && handleClose(); return response; @@ -175,14 +172,9 @@ export const CreateUpdateIssueModal: React.FC = observer((prop message: "Issue could not be created. Please try again.", }); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED" }, path: router.asPath, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); } }; @@ -198,14 +190,9 @@ export const CreateUpdateIssueModal: React.FC = observer((prop message: "Issue updated successfully.", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS" }, path: router.asPath, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); handleClose(); return response; @@ -216,14 +203,9 @@ export const CreateUpdateIssueModal: React.FC = observer((prop message: "Issue could not be created. Please try again.", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...payload, state: "FAILED" }, path: router.asPath, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); } }; diff --git a/web/components/issues/peek-overview/root.tsx b/web/components/issues/peek-overview/root.tsx index f14018ed4..b491ebe36 100644 --- a/web/components/issues/peek-overview/root.tsx +++ b/web/components/issues/peek-overview/root.tsx @@ -11,6 +11,7 @@ import { TIssue } from "@plane/types"; // constants import { EUserProjectRoles } from "constants/project"; import { EIssuesStoreType } from "constants/issue"; +import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker"; interface IIssuePeekOverview { is_archived?: boolean; @@ -103,7 +104,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Issue updated successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: Object.keys(data).join(","), @@ -113,7 +114,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue peek-overview" }, path: router.asPath, }); @@ -135,7 +136,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Issue deleted successfully", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" }, path: router.asPath, }); @@ -146,7 +147,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Issue delete failed", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" }, path: router.asPath, }); @@ -161,7 +162,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Issue added to issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: "cycle_id", @@ -171,7 +172,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue peek-overview" }, updates: { changed_property: "cycle_id", @@ -195,7 +196,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Cycle removed from issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: "cycle_id", @@ -210,7 +211,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Cycle remove from issue failed", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue peek-overview" }, updates: { changed_property: "cycle_id", @@ -229,7 +230,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Module added to issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: "module_id", @@ -239,7 +240,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" }, updates: { changed_property: "module_id", @@ -263,7 +264,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Module removed from issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: "module_id", @@ -273,7 +274,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" }, updates: { changed_property: "module_id", diff --git a/web/components/modules/delete-module-modal.tsx b/web/components/modules/delete-module-modal.tsx index 2727b4e3b..636a828ae 100644 --- a/web/components/modules/delete-module-modal.tsx +++ b/web/components/modules/delete-module-modal.tsx @@ -11,6 +11,8 @@ import { Button } from "@plane/ui"; import { AlertTriangle } from "lucide-react"; // types import type { IModule } from "@plane/types"; +// constants +import { MODULE_DELETED } from "constants/event-tracker"; type Props = { data: IModule; @@ -51,7 +53,7 @@ export const DeleteModuleModal: React.FC = observer((props) => { message: "Module deleted successfully.", }); captureModuleEvent({ - eventName: "Module deleted", + eventName: MODULE_DELETED, payload: { ...data, state: "SUCCESS" }, }); }) @@ -62,7 +64,7 @@ export const DeleteModuleModal: React.FC = observer((props) => { message: "Module could not be deleted. Please try again.", }); captureModuleEvent({ - eventName: "Module deleted", + eventName: MODULE_DELETED, payload: { ...data, state: "FAILED" }, }); }) diff --git a/web/components/modules/form.tsx b/web/components/modules/form.tsx index be0792caa..8fa63e826 100644 --- a/web/components/modules/form.tsx +++ b/web/components/modules/form.tsx @@ -11,7 +11,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { IModule } from "@plane/types"; type Props = { - handleFormSubmit: (values: Partial) => Promise; + handleFormSubmit: (values: Partial, dirtyFields: any) => Promise; handleClose: () => void; status: boolean; projectId: string; @@ -36,7 +36,7 @@ export const ModuleForm: React.FC = ({ data, }) => { const { - formState: { errors, isSubmitting }, + formState: { errors, isSubmitting, dirtyFields }, handleSubmit, watch, control, @@ -53,7 +53,7 @@ export const ModuleForm: React.FC = ({ }); const handleCreateUpdateModule = async (formData: Partial) => { - await handleFormSubmit(formData); + await handleFormSubmit(formData, dirtyFields); reset({ ...defaultValues, diff --git a/web/components/modules/modal.tsx b/web/components/modules/modal.tsx index 0852434c3..7990386df 100644 --- a/web/components/modules/modal.tsx +++ b/web/components/modules/modal.tsx @@ -9,6 +9,8 @@ import useToast from "hooks/use-toast"; import { ModuleForm } from "components/modules"; // types import type { IModule } from "@plane/types"; +// constants +import { MODULE_CREATED, MODULE_UPDATED } from "constants/event-tracker"; type Props = { isOpen: boolean; @@ -59,7 +61,7 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { message: "Module created successfully.", }); captureModuleEvent({ - eventName: "Module created", + eventName: MODULE_CREATED, payload: { ...res, state: "SUCCESS" }, }); }) @@ -70,13 +72,13 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { message: err.detail ?? "Module could not be created. Please try again.", }); captureModuleEvent({ - eventName: "Module created", + eventName: MODULE_CREATED, payload: { ...data, state: "FAILED" }, }); }); }; - const handleUpdateModule = async (payload: Partial) => { + const handleUpdateModule = async (payload: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId || !data) return; const selectedProjectId = payload.project ?? projectId.toString(); @@ -90,8 +92,8 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { message: "Module updated successfully.", }); captureModuleEvent({ - eventName: "Module updated", - payload: { ...res, state: "SUCCESS" }, + eventName: MODULE_UPDATED, + payload: { ...res, changed_properties: Object.keys(dirtyFields), state: "SUCCESS" }, }); }) .catch((err) => { @@ -101,20 +103,20 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { message: err.detail ?? "Module could not be updated. Please try again.", }); captureModuleEvent({ - eventName: "Module updated", + eventName: MODULE_UPDATED, payload: { ...data, state: "FAILED" }, }); }); }; - const handleFormSubmit = async (formData: Partial) => { + const handleFormSubmit = async (formData: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId) return; const payload: Partial = { ...formData, }; if (!data) await handleCreateModule(payload); - else await handleUpdateModule(payload); + else await handleUpdateModule(payload, dirtyFields); }; useEffect(() => { diff --git a/web/components/modules/module-card-item.tsx b/web/components/modules/module-card-item.tsx index 3d83be010..219942550 100644 --- a/web/components/modules/module-card-item.tsx +++ b/web/components/modules/module-card-item.tsx @@ -16,6 +16,7 @@ import { renderFormattedDate } from "helpers/date-time.helper"; // constants import { MODULE_STATUS } from "constants/module"; import { EUserProjectRoles } from "constants/project"; +import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "constants/event-tracker"; type Props = { moduleId: string; @@ -36,7 +37,7 @@ export const ModuleCardItem: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureEvent } = useEventTracker(); // derived values const moduleDetails = getModuleById(moduleId); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -46,13 +47,21 @@ export const ModuleCardItem: React.FC = observer((props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the module to favorites. Please try again.", + addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId) + .then(() => { + captureEvent(MODULE_FAVORITED, { + module_id: moduleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the module to favorites. Please try again.", + }); }); - }); }; const handleRemoveFromFavorites = (e: React.MouseEvent) => { @@ -60,13 +69,21 @@ export const ModuleCardItem: React.FC = observer((props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't remove the module from favorites. Please try again.", + removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId) + .then(() => { + captureEvent(MODULE_UNFAVORITED, { + module_id: moduleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't remove the module from favorites. Please try again.", + }); }); - }); }; const handleCopyText = (e: React.MouseEvent) => { @@ -84,14 +101,14 @@ export const ModuleCardItem: React.FC = observer((props) => { const handleEditModule = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setTrackElement("Modules page board layout"); + setTrackElement("Modules page grid layout"); setEditModal(true); }; const handleDeleteModule = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setTrackElement("Modules page board layout"); + setTrackElement("Modules page grid layout"); setDeleteModal(true); }; diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx index 7232c8815..23e3e5ed4 100644 --- a/web/components/modules/module-list-item.tsx +++ b/web/components/modules/module-list-item.tsx @@ -16,6 +16,7 @@ import { renderFormattedDate } from "helpers/date-time.helper"; // constants import { MODULE_STATUS } from "constants/module"; import { EUserProjectRoles } from "constants/project"; +import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "constants/event-tracker"; type Props = { moduleId: string; @@ -36,7 +37,7 @@ export const ModuleListItem: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureEvent } = useEventTracker(); // derived values const moduleDetails = getModuleById(moduleId); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -46,13 +47,21 @@ export const ModuleListItem: React.FC = observer((props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the module to favorites. Please try again.", + addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId) + .then(() => { + captureEvent(MODULE_FAVORITED, { + module_id: moduleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the module to favorites. Please try again.", + }); }); - }); }; const handleRemoveFromFavorites = (e: React.MouseEvent) => { @@ -60,13 +69,21 @@ export const ModuleListItem: React.FC = observer((props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't remove the module from favorites. Please try again.", + removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId) + .then(() => { + captureEvent(MODULE_UNFAVORITED, { + module_id: moduleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't remove the module from favorites. Please try again.", + }); }); - }); }; const handleCopyText = (e: React.MouseEvent) => { diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index 947885f9a..6b7ac1b3a 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -34,6 +34,7 @@ import { ILinkDetails, IModule, ModuleLink } from "@plane/types"; // constant import { MODULE_STATUS } from "constants/module"; import { EUserProjectRoles } from "constants/project"; +import { MODULE_LINK_CREATED, MODULE_LINK_DELETED, MODULE_LINK_UPDATED, MODULE_UPDATED } from "constants/event-tracker"; const defaultValues: Partial = { lead: "", @@ -66,7 +67,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink } = useModule(); - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker(); const moduleDetails = getModuleById(moduleId); const { setToastAlert } = useToast(); @@ -77,7 +78,19 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const submitChanges = (data: Partial) => { if (!workspaceSlug || !projectId || !moduleId) return; - updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data); + updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data) + .then((res) => { + captureModuleEvent({ + eventName: MODULE_UPDATED, + payload: { ...res, changed_properties: Object.keys(data)[0], element: "Right side-peek", state: "SUCCESS" }, + }); + }) + .catch((_) => { + captureModuleEvent({ + eventName: MODULE_UPDATED, + payload: { ...data, state: "FAILED" }, + }); + }); }; const handleCreateLink = async (formData: ModuleLink) => { @@ -87,6 +100,10 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload) .then(() => { + captureEvent(MODULE_LINK_CREATED, { + module_id: moduleId, + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Module link created", @@ -109,6 +126,10 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload) .then(() => { + captureEvent(MODULE_LINK_UPDATED, { + module_id: moduleId, + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Module link updated", @@ -129,6 +150,10 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { deleteModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId) .then(() => { + captureEvent(MODULE_LINK_DELETED, { + module_id: moduleId, + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Module link deleted", @@ -187,8 +212,8 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { if (watch("start_date") && watch("target_date") && watch("start_date") !== "" && watch("start_date") !== "") { submitChanges({ - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), target_date: renderFormattedPayloadDate(`${watch("target_date")}`), + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), }); setToastAlert({ type: "success", @@ -294,7 +319,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { ( + render={({ field: { value, onChange } }) => ( void; @@ -28,6 +33,7 @@ type NotificationCardProps = { export const NotificationCard: React.FC = (props) => { const { + selectedTab, notification, isSnoozedTabOpen, closePopover, @@ -37,11 +43,78 @@ export const NotificationCard: React.FC = (props) => { setSelectedNotificationForSnooze, markSnoozeNotification, } = props; + // store hooks + const { captureEvent } = useEventTracker(); const router = useRouter(); const { workspaceSlug } = router.query; - + // states + const [showSnoozeOptions, setshowSnoozeOptions] = React.useState(false); + // toast alert const { setToastAlert } = useToast(); + // refs + const snoozeRef = useRef(null); + + const moreOptions = [ + { + id: 1, + name: notification.read_at ? "Mark as unread" : "Mark as read", + icon: , + onClick: () => { + markNotificationReadStatusToggle(notification.id).then(() => { + setToastAlert({ + title: notification.read_at ? "Notification marked as read" : "Notification marked as unread", + type: "success", + }); + }); + }, + }, + { + id: 2, + name: notification.archived_at ? "Unarchive" : "Archive", + icon: notification.archived_at ? ( + + ) : ( + + ), + onClick: () => { + markNotificationArchivedStatus(notification.id).then(() => { + setToastAlert({ + title: notification.archived_at ? "Notification un-archived" : "Notification archived", + type: "success", + }); + }); + }, + }, + ]; + + const snoozeOptionOnClick = (date: Date | null) => { + if (!date) { + setSelectedNotificationForSnooze(notification.id); + return; + } + markSnoozeNotification(notification.id, date).then(() => { + setToastAlert({ + title: `Notification snoozed till ${renderFormattedDate(date)}`, + type: "success", + }); + }); + }; + + // close snooze options on outside click + useEffect(() => { + const handleClickOutside = (event: any) => { + if (snoozeRef.current && !snoozeRef.current?.contains(event.target)) { + setshowSnoozeOptions(false); + } + }; + document.addEventListener("mousedown", handleClickOutside, true); + document.addEventListener("touchend", handleClickOutside, true); + return () => { + document.removeEventListener("mousedown", handleClickOutside, true); + document.removeEventListener("touchend", handleClickOutside, true); + }; + }, []); if (isSnoozedTabOpen && new Date(notification.snoozed_till!) < new Date()) return null; @@ -49,6 +122,10 @@ export const NotificationCard: React.FC = (props) => { { markNotificationReadStatus(notification.id); + captureEvent(ISSUE_OPENED, { + issue_id: notification.data.issue.id, + element: "notification", + }); closePopover(); }} href={`/${workspaceSlug}/projects/${notification.project}/${ @@ -87,57 +164,136 @@ export const NotificationCard: React.FC = (props) => { )}
- {!notification.message ? ( -
- - {notification.triggered_by_details.is_bot - ? notification.triggered_by_details.first_name - : notification.triggered_by_details.display_name}{" "} - - {notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.verb}{" "} - {notification.data.issue_activity.field === "comment" - ? "commented" - : notification.data.issue_activity.field === "None" - ? null - : replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "} - {notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.field !== "None" - ? "to" - : ""} - - {" "} - {notification.data.issue_activity.field !== "None" ? ( - notification.data.issue_activity.field !== "comment" ? ( - notification.data.issue_activity.field === "target_date" ? ( - renderFormattedDate(notification.data.issue_activity.new_value) - ) : notification.data.issue_activity.field === "attachment" ? ( - "the issue" - ) : notification.data.issue_activity.field === "description" ? ( - stripAndTruncateHTML(notification.data.issue_activity.new_value, 55) +
+ {!notification.message ? ( +
+ + {notification.triggered_by_details.is_bot + ? notification.triggered_by_details.first_name + : notification.triggered_by_details.display_name}{" "} + + {notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.verb}{" "} + {notification.data.issue_activity.field === "comment" + ? "commented" + : notification.data.issue_activity.field === "None" + ? null + : replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "} + {notification.data.issue_activity.field !== "comment" && notification.data.issue_activity.field !== "None" + ? "to" + : ""} + + {" "} + {notification.data.issue_activity.field !== "None" ? ( + notification.data.issue_activity.field !== "comment" ? ( + notification.data.issue_activity.field === "target_date" ? ( + renderFormattedDate(notification.data.issue_activity.new_value) + ) : notification.data.issue_activity.field === "attachment" ? ( + "the issue" + ) : notification.data.issue_activity.field === "description" ? ( + stripAndTruncateHTML(notification.data.issue_activity.new_value, 55) + ) : ( + notification.data.issue_activity.new_value + ) ) : ( - notification.data.issue_activity.new_value + + {`"`} + {notification.data.issue_activity.new_value.length > 55 + ? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..." + : notification.data.issue_activity.issue_comment} + {`"`} + ) ) : ( - - {`"`} - {notification.data.issue_activity.new_value.length > 55 - ? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..." - : notification.data.issue_activity.issue_comment} - {`"`} - - ) - ) : ( - "the issue and assigned it to you." + "the issue and assigned it to you." + )} + +
+ ) : ( +
+ {notification.message} +
+ )} +
+ + {({ open }) => ( + <> + + + + {open && ( + +
+ {moreOptions.map((item) => ( + + {({ close }) => ( + + )} + + ))} + +
{ + e.stopPropagation(); + e.preventDefault(); + setshowSnoozeOptions(true); + }} + className="flex gap-x-2 items-center p-1.5" + > + + Snooze +
+
+
+
+ )} + )} - +
+ {showSnoozeOptions && ( +
+ {snoozeOptions.map((item) => ( +

{ + e.stopPropagation(); + e.preventDefault(); + setshowSnoozeOptions(false); + snoozeOptionOnClick(item.value); + }} + > + {item.label} +

+ ))} +
+ )}
- ) : ( -
- {notification.message} -
- )} +
-

+

{truncateText( `${notification.data.issue.identifier}-${notification.data.issue.sequence_id} ${notification.data.issue.name}`, 50 @@ -152,7 +308,7 @@ export const NotificationCard: React.FC = (props) => {

) : ( -

{calculateTimeAgo(notification.created_at)}

+

{calculateTimeAgo(notification.created_at)}

)}
@@ -164,6 +320,11 @@ export const NotificationCard: React.FC = (props) => { icon: , onClick: () => { markNotificationReadStatusToggle(notification.id).then(() => { + captureEvent(NOTIFICATIONS_READ, { + issue_id: notification.data.issue.id, + tab: selectedTab, + state: "SUCCESS", + }); setToastAlert({ title: notification.read_at ? "Notification marked as read" : "Notification marked as unread", type: "success", @@ -181,6 +342,11 @@ export const NotificationCard: React.FC = (props) => { ), onClick: () => { markNotificationArchivedStatus(notification.id).then(() => { + captureEvent(NOTIFICATION_ARCHIVED, { + issue_id: notification.data.issue.id, + tab: selectedTab, + state: "SUCCESS", + }); setToastAlert({ title: notification.archived_at ? "Notification un-archived" : "Notification archived", type: "success", @@ -195,7 +361,6 @@ export const NotificationCard: React.FC = (props) => { onClick={(e) => { e.stopPropagation(); e.preventDefault(); - item.onClick(); }} key={item.id} @@ -208,9 +373,6 @@ export const NotificationCard: React.FC = (props) => { void }) => { - e.stopPropagation(); - }} customButton={
@@ -231,6 +393,11 @@ export const NotificationCard: React.FC = (props) => { } markSnoozeNotification(notification.id, item.value).then(() => { + captureEvent(NOTIFICATION_SNOOZED, { + issue_id: notification.data.issue.id, + tab: selectedTab, + state: "SUCCESS", + }); setToastAlert({ title: `Notification snoozed till ${renderFormattedDate(item.value)}`, type: "success", diff --git a/web/components/notifications/notification-header.tsx b/web/components/notifications/notification-header.tsx index 39bf0e8fb..a9fef37aa 100644 --- a/web/components/notifications/notification-header.tsx +++ b/web/components/notifications/notification-header.tsx @@ -1,11 +1,22 @@ import React from "react"; import { ArrowLeft, CheckCheck, Clock, ListFilter, MoreVertical, RefreshCw, X } from "lucide-react"; +// components +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; // ui import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui"; +// hooks +import { useEventTracker } from "hooks/store"; // helpers import { getNumberCount } from "helpers/string.helper"; // type import type { NotificationType, NotificationCount } from "@plane/types"; +// constants +import { + ARCHIVED_NOTIFICATIONS, + NOTIFICATIONS_READ, + SNOOZED_NOTIFICATIONS, + UNREAD_NOTIFICATIONS, +} from "constants/event-tracker"; type NotificationHeaderProps = { notificationCount?: NotificationCount | null; @@ -39,6 +50,8 @@ export const NotificationHeader: React.FC = (props) => setSelectedTab, markAllNotificationsAsRead, } = props; + // store hooks + const { captureEvent } = useEventTracker(); const notificationTabs: Array<{ label: string; @@ -65,7 +78,11 @@ export const NotificationHeader: React.FC = (props) => return ( <>
-

Notifications

+
+ +

Notifications

+
+
- +
+ + + +
diff --git a/web/components/notifications/notification-popover.tsx b/web/components/notifications/notification-popover.tsx index 4b55ea4cb..a8cdd4264 100644 --- a/web/components/notifications/notification-popover.tsx +++ b/web/components/notifications/notification-popover.tsx @@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite"; // hooks import { useApplication } from "hooks/store"; import useUserNotification from "hooks/use-user-notifications"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { EmptyState } from "components/common"; import { SnoozeNotificationModal, NotificationCard, NotificationHeader } from "components/notifications"; @@ -15,8 +16,12 @@ import emptyNotification from "public/empty-state/notification.svg"; import { getNumberCount } from "helpers/string.helper"; export const NotificationPopover = observer(() => { + // states + const [isActive, setIsActive] = React.useState(false); // store hooks const { theme: themeStore } = useApplication(); + // refs + const notificationPopoverRef = React.useRef(null); const { notifications, @@ -44,8 +49,11 @@ export const NotificationPopover = observer(() => { setFetchNotifications, markAllNotificationsAsRead, } = useUserNotification(); - const isSidebarCollapsed = themeStore.sidebarCollapsed; + useOutsideClickDetector(notificationPopoverRef, () => { + // if snooze modal is open, then don't close the popover + if (selectedNotificationForSnooze === null) setIsActive(false); + }); return ( <> @@ -54,141 +62,143 @@ export const NotificationPopover = observer(() => { onClose={() => setSelectedNotificationForSnooze(null)} onSubmit={markSnoozeNotification} notification={notifications?.find((notification) => notification.id === selectedNotificationForSnooze) || null} - onSuccess={() => { - setSelectedNotificationForSnooze(null); - }} + onSuccess={() => setSelectedNotificationForSnooze(null)} /> - - {({ open: isActive, close: closePopover }) => { - if (isActive) setFetchNotifications(true); + + <> + + + + + + setIsActive(false)} + isRefreshing={isRefreshing} + snoozed={snoozed} + archived={archived} + readNotification={readNotification} + selectedTab={selectedTab} + setSnoozed={setSnoozed} + setArchived={setArchived} + setReadNotification={setReadNotification} + setSelectedTab={setSelectedTab} + markAllNotificationsAsRead={markAllNotificationsAsRead} + /> - return ( - <> - - - - {isSidebarCollapsed ? null : Notifications} - {totalNotificationCount && totalNotificationCount > 0 ? ( - isSidebarCollapsed ? ( - - ) : ( - - {getNumberCount(totalNotificationCount)} - - ) - ) : null} - - - - - - - {notifications ? ( - notifications.length > 0 ? ( -
-
- {notifications.map((notification) => ( - - ))} -
- {isLoadingMore && ( -
-
- - Loading... -
-

Loading notifications

-
- )} - {hasMore && !isLoadingMore && ( - - )} -
- ) : ( -
- 0 ? ( +
+
+ {notifications.map((notification) => ( + setIsActive(false)} + notification={notification} + markNotificationArchivedStatus={markNotificationArchivedStatus} + markNotificationReadStatus={markNotificationAsRead} + markNotificationReadStatusToggle={markNotificationReadStatus} + setSelectedNotificationForSnooze={setSelectedNotificationForSnooze} + markSnoozeNotification={markSnoozeNotification} /> + ))} +
+ {isLoadingMore && ( +
+
+ + Loading... +
+

Loading notifications

- ) - ) : ( - - - - - - - - )} - - - - ); - }} + )} + {hasMore && !isLoadingMore && ( + + )} +
+ ) : ( +
+ +
+ ) + ) : ( + + + + + + + + )} + + + ); diff --git a/web/components/notifications/select-snooze-till-modal.tsx b/web/components/notifications/select-snooze-till-modal.tsx index ab3497bb8..2ad4b0ef2 100644 --- a/web/components/notifications/select-snooze-till-modal.tsx +++ b/web/components/notifications/select-snooze-till-modal.tsx @@ -109,7 +109,12 @@ export const SnoozeNotificationModal: FC = (props) => { }; const handleClose = () => { - onClose(); + // This is a workaround to fix the issue of the Notification popover modal close on closing this modal + const closeTimeout = setTimeout(() => { + onClose(); + clearTimeout(closeTimeout); + }, 50); + const timeout = setTimeout(() => { reset({ ...defaultValues }); clearTimeout(timeout); @@ -142,7 +147,7 @@ export const SnoozeNotificationModal: FC = (props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - +
@@ -156,8 +161,8 @@ export const SnoozeNotificationModal: FC = (props) => {
-
-
+
+
Pick a date
void; @@ -58,11 +60,19 @@ export const Invitations: React.FC = (props) => { if (invitationsRespond.length <= 0) return; setIsJoiningWorkspaces(true); + const invitation = invitations?.find((invitation) => invitation.id === invitationsRespond[0]); await workspaceService .joinWorkspaces({ invitations: invitationsRespond }) - .then(async (res) => { - captureEvent("Member accepted", { ...res, state: "SUCCESS", accepted_from: "App" }); + .then(async () => { + captureEvent(MEMBER_ACCEPTED, { + member_id: invitation?.id, + role: getUserRole(invitation?.role!), + project_id: undefined, + accepted_from: "App", + state: "SUCCESS", + element: "Workspace invitations page", + }); await fetchWorkspaces(); await mutate(USER_WORKSPACES); await updateLastWorkspace(); @@ -71,7 +81,14 @@ export const Invitations: React.FC = (props) => { }) .catch((error) => { console.error(error); - captureEvent("Member accepted", { state: "FAILED", accepted_from: "App" }); + captureEvent(MEMBER_ACCEPTED, { + member_id: invitation?.id, + role: getUserRole(invitation?.role!), + project_id: undefined, + accepted_from: "App", + state: "FAILED", + element: "Workspace invitations page", + }); }) .finally(() => setIsJoiningWorkspaces(false)); }; diff --git a/web/components/onboarding/invite-members.tsx b/web/components/onboarding/invite-members.tsx index dc6e2db96..561a428d6 100644 --- a/web/components/onboarding/invite-members.tsx +++ b/web/components/onboarding/invite-members.tsx @@ -18,6 +18,7 @@ import { Check, ChevronDown, Plus, XCircle } from "lucide-react"; import { WorkspaceService } from "services/workspace.service"; // hooks import useToast from "hooks/use-toast"; +import { useEventTracker } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // components @@ -28,6 +29,9 @@ import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; import { IUser, IWorkspace, TOnboardingSteps } from "@plane/types"; // constants import { EUserWorkspaceRoles, ROLE } from "constants/workspace"; +import { MEMBER_INVITED } from "constants/event-tracker"; +// helpers +import { getUserRole } from "helpers/user.helper"; // assets import user1 from "public/users/user-1.png"; import user2 from "public/users/user-2.png"; @@ -267,6 +271,8 @@ export const InviteMembers: React.FC = (props) => { const { setToastAlert } = useToast(); const { resolvedTheme } = useTheme(); + // store hooks + const { captureEvent } = useEventTracker(); const { control, @@ -305,6 +311,17 @@ export const InviteMembers: React.FC = (props) => { })), }) .then(async () => { + captureEvent(MEMBER_INVITED, { + emails: [ + ...payload.emails.map((email) => ({ + email: email.email, + role: getUserRole(email.role), + })), + ], + project_id: undefined, + state: "SUCCESS", + element: "Onboarding", + }); setToastAlert({ type: "success", title: "Success!", @@ -313,13 +330,18 @@ export const InviteMembers: React.FC = (props) => { await nextStep(); }) - .catch((err) => + .catch((err) => { + captureEvent(MEMBER_INVITED, { + project_id: undefined, + state: "FAILED", + element: "Onboarding", + }); setToastAlert({ type: "error", title: "Error!", message: err?.error, - }) - ); + }); + }); }; const appendField = () => { diff --git a/web/components/onboarding/tour/root.tsx b/web/components/onboarding/tour/root.tsx index 6e1de15dd..c09a2a94c 100644 --- a/web/components/onboarding/tour/root.tsx +++ b/web/components/onboarding/tour/root.tsx @@ -15,6 +15,8 @@ import CyclesTour from "public/onboarding/cycles.webp"; import ModulesTour from "public/onboarding/modules.webp"; import ViewsTour from "public/onboarding/views.webp"; import PagesTour from "public/onboarding/pages.webp"; +// constants +import { PRODUCT_TOUR_SKIPPED, PRODUCT_TOUR_STARTED } from "constants/event-tracker"; type Props = { onComplete: () => void; @@ -79,7 +81,7 @@ export const TourRoot: React.FC = observer((props) => { const [step, setStep] = useState("welcome"); // store hooks const { commandPalette: commandPaletteStore } = useApplication(); - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureEvent } = useEventTracker(); const { currentUser } = useUser(); const currentStepIndex = TOUR_STEPS.findIndex((tourStep) => tourStep.key === step); @@ -103,13 +105,22 @@ export const TourRoot: React.FC = observer((props) => {

- @@ -156,8 +167,8 @@ export const TourRoot: React.FC = observer((props) => {
diff --git a/web/components/profile/sidebar.tsx b/web/components/profile/sidebar.tsx index 3ce7747c9..107c1f528 100644 --- a/web/components/profile/sidebar.tsx +++ b/web/components/profile/sidebar.tsx @@ -4,7 +4,7 @@ import useSWR from "swr"; import { Disclosure, Transition } from "@headlessui/react"; import { observer } from "mobx-react-lite"; // hooks -import { useUser } from "hooks/store"; +import { useApplication, useUser } from "hooks/store"; // services import { UserService } from "services/user.service"; // components @@ -18,6 +18,8 @@ import { renderFormattedDate } from "helpers/date-time.helper"; import { renderEmoji } from "helpers/emoji.helper"; // fetch-keys import { USER_PROFILE_PROJECT_SEGREGATION } from "constants/fetch-keys"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { useEffect, useRef } from "react"; // services const userService = new UserService(); @@ -28,6 +30,8 @@ export const ProfileSidebar = observer(() => { const { workspaceSlug, userId } = router.query; // store hooks const { currentUser } = useUser(); + const { theme: themeStore } = useApplication(); + const ref = useRef(null); const { data: userProjectsData } = useSWR( workspaceSlug && userId ? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString()) : null, @@ -36,6 +40,14 @@ export const ProfileSidebar = observer(() => { : null ); + useOutsideClickDetector(ref, () => { + if (themeStore.profileSidebarCollapsed === false) { + if (window.innerWidth < 768) { + themeStore.toggleProfileSidebar(); + } + } + }); + const userDetails = [ { label: "Joined on", @@ -47,8 +59,26 @@ export const ProfileSidebar = observer(() => { }, ]; + useEffect(() => { + const handleToggleProfileSidebar = () => { + if (window && window.innerWidth < 768) { + themeStore.toggleProfileSidebar(true); + } + if (window && themeStore.profileSidebarCollapsed && window.innerWidth >= 768) { + themeStore.toggleProfileSidebar(false); + } + }; + + window.addEventListener("resize", handleToggleProfileSidebar); + handleToggleProfileSidebar(); + return () => window.removeEventListener("resize", handleToggleProfileSidebar); + }, [themeStore]); + return ( -
+
{userProjectsData ? ( <>
@@ -132,13 +162,12 @@ export const ProfileSidebar = observer(() => { {project.assigned_issues > 0 && (
{completedIssuePercentage}%
diff --git a/web/components/project/create-project-modal.tsx b/web/components/project/create-project-modal.tsx index 7d6a0c5e9..db0c284f2 100644 --- a/web/components/project/create-project-modal.tsx +++ b/web/components/project/create-project-modal.tsx @@ -18,6 +18,7 @@ import { getRandomEmoji, renderEmoji } from "helpers/emoji.helper"; import { NETWORK_CHOICES, PROJECT_UNSPLASH_COVERS } from "constants/project"; // constants import { EUserWorkspaceRoles } from "constants/workspace"; +import { PROJECT_CREATED } from "constants/event-tracker"; type Props = { isOpen: boolean; @@ -134,13 +135,8 @@ export const CreateProjectModal: FC = observer((props) => { state: "SUCCESS", }; captureProjectEvent({ - eventName: "Project created", + eventName: PROJECT_CREATED, payload: newPayload, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: res.workspace, - }, }); setToastAlert({ type: "success", @@ -160,16 +156,11 @@ export const CreateProjectModal: FC = observer((props) => { message: err.data[key], }); captureProjectEvent({ - eventName: "Project created", + eventName: PROJECT_CREATED, payload: { ...payload, state: "FAILED", - }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, + } }); }); }); diff --git a/web/components/project/delete-project-modal.tsx b/web/components/project/delete-project-modal.tsx index 7e04a6ebd..791ac3672 100644 --- a/web/components/project/delete-project-modal.tsx +++ b/web/components/project/delete-project-modal.tsx @@ -10,6 +10,8 @@ import useToast from "hooks/use-toast"; import { Button, Input } from "@plane/ui"; // types import type { IProject } from "@plane/types"; +// constants +import { PROJECT_DELETED } from "constants/event-tracker"; type DeleteProjectModal = { isOpen: boolean; @@ -62,13 +64,8 @@ export const DeleteProjectModal: React.FC = (props) => { handleClose(); captureProjectEvent({ - eventName: "Project deleted", + eventName: PROJECT_DELETED, payload: { ...project, state: "SUCCESS", element: "Project general settings" }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); setToastAlert({ type: "success", @@ -78,13 +75,8 @@ export const DeleteProjectModal: React.FC = (props) => { }) .catch(() => { captureProjectEvent({ - eventName: "Project deleted", + eventName: PROJECT_DELETED, payload: { ...project, state: "FAILED", element: "Project general settings" }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); setToastAlert({ type: "error", diff --git a/web/components/project/form.tsx b/web/components/project/form.tsx index f5efa7bc8..f46dee6b9 100644 --- a/web/components/project/form.tsx +++ b/web/components/project/form.tsx @@ -18,6 +18,7 @@ import { renderFormattedDate } from "helpers/date-time.helper"; import { NETWORK_CHOICES } from "constants/project"; // services import { ProjectService } from "services/project"; +import { PROJECT_UPDATED } from "constants/event-tracker"; export interface IProjectDetailsForm { project: IProject; @@ -45,7 +46,7 @@ export const ProjectDetailsForm: FC = (props) => { setValue, setError, reset, - formState: { errors }, + formState: { errors, dirtyFields }, } = useForm({ defaultValues: { ...project, @@ -77,13 +78,15 @@ export const ProjectDetailsForm: FC = (props) => { return updateProject(workspaceSlug.toString(), project.id, payload) .then((res) => { + const changed_properties = Object.keys(dirtyFields); + console.log(dirtyFields); captureProjectEvent({ - eventName: "Project updated", - payload: { ...res, state: "SUCCESS", element: "Project general settings" }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: res.workspace, + eventName: PROJECT_UPDATED, + payload: { + ...res, + changed_properties: changed_properties, + state: "SUCCESS", + element: "Project general settings", }, }); setToastAlert({ @@ -94,13 +97,8 @@ export const ProjectDetailsForm: FC = (props) => { }) .catch((error) => { captureProjectEvent({ - eventName: "Project updated", + eventName: PROJECT_UPDATED, payload: { ...payload, state: "FAILED", element: "Project general settings" }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id, - }, }); setToastAlert({ type: "error", @@ -153,7 +151,7 @@ export const ProjectDetailsForm: FC = (props) => {
{watch("cover_image")!} -
+
diff --git a/web/components/project/leave-project-modal.tsx b/web/components/project/leave-project-modal.tsx index 941bbbaa6..0827568ce 100644 --- a/web/components/project/leave-project-modal.tsx +++ b/web/components/project/leave-project-modal.tsx @@ -11,6 +11,8 @@ import useToast from "hooks/use-toast"; import { Button, Input } from "@plane/ui"; // types import { IProject } from "@plane/types"; +// constants +import { PROJECT_MEMBER_LEAVE } from "constants/event-tracker"; type FormData = { projectName: string; @@ -63,8 +65,9 @@ export const LeaveProjectModal: FC = observer((props) => { .then(() => { handleClose(); router.push(`/${workspaceSlug}/projects`); - captureEvent("Project member leave", { + captureEvent(PROJECT_MEMBER_LEAVE, { state: "SUCCESS", + element: "Project settings members page", }); }) .catch(() => { @@ -73,8 +76,9 @@ export const LeaveProjectModal: FC = observer((props) => { title: "Error!", message: "Something went wrong please try again later.", }); - captureEvent("Project member leave", { + captureEvent(PROJECT_MEMBER_LEAVE, { state: "FAILED", + element: "Project settings members page", }); }); } else { diff --git a/web/components/project/member-list-item.tsx b/web/components/project/member-list-item.tsx index 175cf9bd4..6a27eccd5 100644 --- a/web/components/project/member-list-item.tsx +++ b/web/components/project/member-list-item.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import Link from "next/link"; import { observer } from "mobx-react-lite"; // hooks -import { useMember, useProject, useUser } from "hooks/store"; +import { useEventTracker, useMember, useProject, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { ConfirmProjectMemberRemove } from "components/project"; @@ -14,6 +14,7 @@ import { ChevronDown, Dot, XCircle } from "lucide-react"; // constants import { ROLE } from "constants/workspace"; import { EUserProjectRoles } from "constants/project"; +import { PROJECT_MEMBER_LEAVE } from "constants/event-tracker"; type Props = { userId: string; @@ -35,6 +36,7 @@ export const ProjectMemberListItem: React.FC = observer((props) => { const { project: { removeMemberFromProject, getProjectMemberDetails, updateMember }, } = useMember(); + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); @@ -48,8 +50,11 @@ export const ProjectMemberListItem: React.FC = observer((props) => { if (userDetails.member.id === currentUser?.id) { await leaveProject(workspaceSlug.toString(), projectId.toString()) .then(async () => { + captureEvent(PROJECT_MEMBER_LEAVE, { + state: "SUCCESS", + element: "Project settings members page", + }); await fetchProjects(workspaceSlug.toString()); - router.push(`/${workspaceSlug}/projects`); }) .catch((err) => diff --git a/web/components/project/send-project-invitation-modal.tsx b/web/components/project/send-project-invitation-modal.tsx index 39fb5c974..7c02ce8d0 100644 --- a/web/components/project/send-project-invitation-modal.tsx +++ b/web/components/project/send-project-invitation-modal.tsx @@ -9,9 +9,12 @@ import { useEventTracker, useMember, useUser, useWorkspace } from "hooks/store"; import useToast from "hooks/use-toast"; // ui import { Avatar, Button, CustomSelect, CustomSearchSelect } from "@plane/ui"; +// helpers +import { getUserRole } from "helpers/user.helper"; // constants import { ROLE } from "constants/workspace"; import { EUserProjectRoles } from "constants/project"; +import { PROJECT_MEMBER_ADDED } from "constants/event-tracker"; type Props = { isOpen: boolean; @@ -49,7 +52,6 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { const { membership: { currentProjectRole }, } = useUser(); - const { currentWorkspace } = useWorkspace(); const { project: { projectMemberIds, bulkAddMembersToProject }, workspace: { workspaceMemberIds, getWorkspaceMemberDetails }, @@ -79,7 +81,7 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { const payload = { ...formData }; await bulkAddMembersToProject(workspaceSlug.toString(), projectId.toString(), payload) - .then((res) => { + .then(() => { if (onSuccess) onSuccess(); onClose(); setToastAlert({ @@ -87,32 +89,23 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { type: "success", message: "Members added successfully.", }); - captureEvent( - "Member added", - { - ...res, - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); + captureEvent(PROJECT_MEMBER_ADDED, { + members: [ + ...payload.members.map((member) => ({ + member_id: member.member_id, + role: ROLE[member.role], + })), + ], + state: "SUCCESS", + element: "Project settings members page", + }); }) .catch((error) => { console.error(error); - captureEvent( - "Member added", - { - state: "FAILED", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); + captureEvent(PROJECT_MEMBER_ADDED, { + state: "FAILED", + element: "Project settings members page", + }); }) .finally(() => { reset(defaultValues); diff --git a/web/components/project/settings/features-list.tsx b/web/components/project/settings/features-list.tsx index 34aec4db6..22e69827e 100644 --- a/web/components/project/settings/features-list.tsx +++ b/web/components/project/settings/features-list.tsx @@ -51,12 +51,11 @@ export const ProjectFeaturesList: FC = observer(() => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; // store hooks - const { setTrackElement, captureEvent } = useEventTracker(); + const { captureEvent } = useEventTracker(); const { currentUser, membership: { currentProjectRole }, } = useUser(); - const { currentWorkspace } = useWorkspace(); const { currentProjectDetails, updateProject } = useProject(); const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN; // toast alert @@ -91,14 +90,9 @@ export const ProjectFeaturesList: FC = observer(() => { { - setTrackElement("PROJECT_SETTINGS_FEATURES_PAGE"); captureEvent(`Toggle ${feature.title.toLowerCase()}`, { - workspace_id: currentWorkspace?.id, - workspace_slug: currentWorkspace?.slug, - project_id: currentProjectDetails?.id, - project_name: currentProjectDetails?.name, - project_identifier: currentProjectDetails?.identifier, enabled: !currentProjectDetails?.[feature.property as keyof IProject], + element: "Project settings feature page", }); handleSubmit({ [feature.property]: !currentProjectDetails?.[feature.property as keyof IProject], diff --git a/web/components/states/create-update-state-inline.tsx b/web/components/states/create-update-state-inline.tsx index b12659a81..037cd483d 100644 --- a/web/components/states/create-update-state-inline.tsx +++ b/web/components/states/create-update-state-inline.tsx @@ -13,6 +13,7 @@ import { Button, CustomSelect, Input, Tooltip } from "@plane/ui"; import type { IState } from "@plane/types"; // constants import { GROUP_CHOICES } from "constants/project"; +import { STATE_CREATED, STATE_UPDATED } from "constants/event-tracker"; type Props = { data: IState | null; @@ -36,7 +37,7 @@ export const CreateUpdateStateInline: React.FC = observer((props) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; // store hooks - const { captureEvent, setTrackElement } = useEventTracker(); + const { captureProjectStateEvent, setTrackElement } = useEventTracker(); const { createState, updateState } = useProjectState(); // toast alert const { setToastAlert } = useToast(); @@ -86,9 +87,13 @@ export const CreateUpdateStateInline: React.FC = observer((props) => { title: "Success!", message: "State created successfully.", }); - captureEvent("State created", { - ...res, - state: "SUCCESS", + captureProjectStateEvent({ + eventName: STATE_CREATED, + payload: { + ...res, + state: "SUCCESS", + element: "Project settings states page", + }, }); }) .catch((error) => { @@ -104,8 +109,14 @@ export const CreateUpdateStateInline: React.FC = observer((props) => { title: "Error!", message: "State could not be created. Please try again.", }); - captureEvent("State created", { - state: "FAILED", + + captureProjectStateEvent({ + eventName: STATE_CREATED, + payload: { + ...formData, + state: "FAILED", + element: "Project settings states page", + }, }); }); }; @@ -116,9 +127,13 @@ export const CreateUpdateStateInline: React.FC = observer((props) => { await updateState(workspaceSlug.toString(), projectId.toString(), data.id, formData) .then((res) => { handleClose(); - captureEvent("State updated", { - ...res, - state: "SUCCESS", + captureProjectStateEvent({ + eventName: STATE_UPDATED, + payload: { + ...res, + state: "SUCCESS", + element: "Project settings states page", + }, }); setToastAlert({ type: "success", @@ -139,8 +154,13 @@ export const CreateUpdateStateInline: React.FC = observer((props) => { title: "Error!", message: "State could not be updated. Please try again.", }); - captureEvent("State updated", { - state: "FAILED", + captureProjectStateEvent({ + eventName: STATE_UPDATED, + payload: { + ...formData, + state: "FAILED", + element: "Project settings states page", + }, }); }); }; diff --git a/web/components/states/delete-state-modal.tsx b/web/components/states/delete-state-modal.tsx index 4a0414092..12de38608 100644 --- a/web/components/states/delete-state-modal.tsx +++ b/web/components/states/delete-state-modal.tsx @@ -10,6 +10,8 @@ import useToast from "hooks/use-toast"; import { Button } from "@plane/ui"; // types import type { IState } from "@plane/types"; +// constants +import { STATE_DELETED } from "constants/event-tracker"; type Props = { isOpen: boolean; @@ -25,7 +27,7 @@ export const DeleteStateModal: React.FC = observer((props) => { const router = useRouter(); const { workspaceSlug } = router.query; // store hooks - const { captureEvent } = useEventTracker(); + const { captureProjectStateEvent } = useEventTracker(); const { deleteState } = useProjectState(); // toast alert const { setToastAlert } = useToast(); @@ -42,8 +44,12 @@ export const DeleteStateModal: React.FC = observer((props) => { await deleteState(workspaceSlug.toString(), data.project_id, data.id) .then(() => { - captureEvent("State deleted", { - state: "SUCCESS", + captureProjectStateEvent({ + eventName: STATE_DELETED, + payload: { + ...data, + state: "SUCCESS", + }, }); handleClose(); }) @@ -61,8 +67,12 @@ export const DeleteStateModal: React.FC = observer((props) => { title: "Error!", message: "State could not be deleted. Please try again.", }); - captureEvent("State deleted", { - state: "FAILED", + captureProjectStateEvent({ + eventName: STATE_DELETED, + payload: { + ...data, + state: "FAILED", + }, }); }) .finally(() => { diff --git a/web/components/workspace/create-workspace-form.tsx b/web/components/workspace/create-workspace-form.tsx index 8cb29edde..b4f164469 100644 --- a/web/components/workspace/create-workspace-form.tsx +++ b/web/components/workspace/create-workspace-form.tsx @@ -13,6 +13,7 @@ import { Button, CustomSelect, Input } from "@plane/ui"; import { IWorkspace } from "@plane/types"; // constants import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "constants/workspace"; +import { WORKSPACE_CREATED } from "constants/event-tracker"; type Props = { onSubmit?: (res: IWorkspace) => Promise; @@ -48,7 +49,7 @@ export const CreateWorkspaceForm: FC = observer((props) => { // router const router = useRouter(); // store hooks - const { captureEvent } = useEventTracker(); + const { captureWorkspaceEvent } = useEventTracker(); const { createWorkspace } = useWorkspace(); // toast alert const { setToastAlert } = useToast(); @@ -70,9 +71,13 @@ export const CreateWorkspaceForm: FC = observer((props) => { await createWorkspace(formData) .then(async (res) => { - captureEvent("Workspace created", { - ...res, - state: "SUCCESS", + captureWorkspaceEvent({ + eventName: WORKSPACE_CREATED, + payload: { + ...res, + state: "SUCCESS", + element: "Create workspace page", + }, }); setToastAlert({ type: "success", @@ -83,14 +88,18 @@ export const CreateWorkspaceForm: FC = observer((props) => { if (onSubmit) await onSubmit(res); }) .catch(() => { + captureWorkspaceEvent({ + eventName: WORKSPACE_CREATED, + payload: { + state: "FAILED", + element: "Create workspace page", + }, + }); setToastAlert({ type: "error", title: "Error!", message: "Workspace could not be created. Please try again.", }); - captureEvent("Workspace created", { - state: "FAILED", - }); }); } else setSlugError(true); }) @@ -100,9 +109,6 @@ export const CreateWorkspaceForm: FC = observer((props) => { title: "Error!", message: "Some error occurred while creating workspace. Please try again.", }); - captureEvent("Workspace created", { - state: "FAILED", - }); }); }; diff --git a/web/components/workspace/delete-workspace-modal.tsx b/web/components/workspace/delete-workspace-modal.tsx index acfe0648d..a90ac9cdf 100644 --- a/web/components/workspace/delete-workspace-modal.tsx +++ b/web/components/workspace/delete-workspace-modal.tsx @@ -11,6 +11,8 @@ import useToast from "hooks/use-toast"; import { Button, Input } from "@plane/ui"; // types import type { IWorkspace } from "@plane/types"; +// constants +import { WORKSPACE_DELETED } from "constants/event-tracker"; type Props = { isOpen: boolean; @@ -28,7 +30,7 @@ export const DeleteWorkspaceModal: React.FC = observer((props) => { // router const router = useRouter(); // store hooks - const { captureEvent } = useEventTracker(); + const { captureWorkspaceEvent } = useEventTracker(); const { deleteWorkspace } = useWorkspace(); // toast alert const { setToastAlert } = useToast(); @@ -59,9 +61,13 @@ export const DeleteWorkspaceModal: React.FC = observer((props) => { .then((res) => { handleClose(); router.push("/"); - captureEvent("Workspace deleted", { - res, - state: "SUCCESS", + captureWorkspaceEvent({ + eventName: WORKSPACE_DELETED, + payload: { + ...data, + state: "SUCCESS", + element: "Workspace general settings page", + }, }); setToastAlert({ type: "success", @@ -75,8 +81,13 @@ export const DeleteWorkspaceModal: React.FC = observer((props) => { title: "Error!", message: "Something went wrong. Please try again later.", }); - captureEvent("Workspace deleted", { - state: "FAILED", + captureWorkspaceEvent({ + eventName: WORKSPACE_DELETED, + payload: { + ...data, + state: "FAILED", + element: "Workspace general settings page", + }, }); }); }; diff --git a/web/components/workspace/settings/members-list-item.tsx b/web/components/workspace/settings/members-list-item.tsx index 9fa5962a3..76c9bbedf 100644 --- a/web/components/workspace/settings/members-list-item.tsx +++ b/web/components/workspace/settings/members-list-item.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { ChevronDown, Dot, XCircle } from "lucide-react"; // hooks -import { useMember, useUser } from "hooks/store"; +import { useEventTracker, useMember, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { ConfirmWorkspaceMemberRemove } from "components/workspace"; @@ -12,6 +12,7 @@ import { ConfirmWorkspaceMemberRemove } from "components/workspace"; import { CustomSelect, Tooltip } from "@plane/ui"; // constants import { EUserWorkspaceRoles, ROLE } from "constants/workspace"; +import { WORKSPACE_MEMBER_lEAVE } from "constants/event-tracker"; type Props = { memberId: string; @@ -33,6 +34,7 @@ export const WorkspaceMembersListItem: FC = observer((props) => { const { workspace: { updateMember, removeMemberFromWorkspace, getWorkspaceMemberDetails }, } = useMember(); + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); // derived values @@ -42,7 +44,13 @@ export const WorkspaceMembersListItem: FC = observer((props) => { if (!workspaceSlug || !currentUserSettings) return; await leaveWorkspace(workspaceSlug.toString()) - .then(() => router.push("/profile")) + .then(() => { + captureEvent(WORKSPACE_MEMBER_lEAVE, { + state: "SUCCESS", + element: "Workspace settings members page", + }); + router.push("/profile"); + }) .catch((err) => setToastAlert({ type: "error", diff --git a/web/components/workspace/settings/workspace-details.tsx b/web/components/workspace/settings/workspace-details.tsx index e0ac68a24..44da4291f 100644 --- a/web/components/workspace/settings/workspace-details.tsx +++ b/web/components/workspace/settings/workspace-details.tsx @@ -19,6 +19,7 @@ import { copyUrlToClipboard } from "helpers/string.helper"; import { IWorkspace } from "@plane/types"; // constants import { EUserWorkspaceRoles, ORGANIZATION_SIZE } from "constants/workspace"; +import { WORKSPACE_UPDATED } from "constants/event-tracker"; const defaultValues: Partial = { name: "", @@ -37,7 +38,7 @@ export const WorkspaceDetails: FC = observer(() => { const [isImageRemoving, setIsImageRemoving] = useState(false); const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); // store hooks - const { captureEvent } = useEventTracker(); + const { captureWorkspaceEvent } = useEventTracker(); const { membership: { currentWorkspaceRole }, } = useUser(); @@ -68,9 +69,13 @@ export const WorkspaceDetails: FC = observer(() => { await updateWorkspace(currentWorkspace.slug, payload) .then((res) => { - captureEvent("Workspace updated", { - ...res, - state: "SUCCESS", + captureWorkspaceEvent({ + eventName: WORKSPACE_UPDATED, + payload: { + ...res, + state: "SUCCESS", + element: "Workspace general settings page", + }, }); setToastAlert({ title: "Success", @@ -79,8 +84,12 @@ export const WorkspaceDetails: FC = observer(() => { }); }) .catch((err) => { - captureEvent("Workspace updated", { - state: "FAILED", + captureWorkspaceEvent({ + eventName: WORKSPACE_UPDATED, + payload: { + state: "FAILED", + element: "Workspace general settings page", + }, }); console.error(err); }); diff --git a/web/components/workspace/sidebar-dropdown.tsx b/web/components/workspace/sidebar-dropdown.tsx index 4528882dd..87bb4c868 100644 --- a/web/components/workspace/sidebar-dropdown.tsx +++ b/web/components/workspace/sidebar-dropdown.tsx @@ -222,7 +222,6 @@ export const WorkspaceSidebarDropdown = observer(() => {
setTrackElement("APP_SIDEBAR_WORKSPACE_DROPDOWN")} className="w-full" > { // store hooks const { theme: themeStore } = useApplication(); + const { captureEvent } = useEventTracker(); const { membership: { currentWorkspaceRole }, } = useUser(); @@ -27,10 +29,13 @@ export const WorkspaceSidebarMenu = observer(() => { // computed const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST; - const handleLinkClick = () => { + const handleLinkClick = (itemKey: string) => { if (window.innerWidth < 768) { themeStore.toggleSidebar(); } + captureEvent(SIDEBAR_CLICKED, { + destination: itemKey, + }); }; return ( @@ -38,11 +43,8 @@ export const WorkspaceSidebarMenu = observer(() => { {SIDEBAR_MENU_ITEMS.map( (link) => workspaceMemberInfo >= link.access && ( - - + handleLinkClick(link.key)}> + { disabled={!themeStore?.sidebarCollapsed} >
{ = observer((props) => { const { workspaceSlug } = router.query; // store hooks const { deleteGlobalView } = useGlobalView(); + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); @@ -39,13 +42,23 @@ export const DeleteGlobalViewModal: React.FC = observer((props) => { setIsDeleteLoading(true); await deleteGlobalView(workspaceSlug.toString(), data.id) - .catch(() => + .then(() => { + captureEvent(GLOBAL_VIEW_DELETED, { + view_id: data.id, + state: "SUCCESS", + }); + }) + .catch(() => { + captureEvent(GLOBAL_VIEW_DELETED, { + view_id: data.id, + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: "Something went wrong while deleting the view. Please try again.", - }) - ) + }); + }) .finally(() => { setIsDeleteLoading(false); handleClose(); diff --git a/web/components/workspace/views/header.tsx b/web/components/workspace/views/header.tsx index 9c9b40c47..43375cb24 100644 --- a/web/components/workspace/views/header.tsx +++ b/web/components/workspace/views/header.tsx @@ -4,11 +4,12 @@ import Link from "next/link"; import { observer } from "mobx-react-lite"; import { Plus } from "lucide-react"; // store hooks -import { useGlobalView, useUser } from "hooks/store"; +import { useEventTracker, useGlobalView, useUser } from "hooks/store"; // components import { CreateUpdateWorkspaceViewModal } from "components/workspace"; // constants import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "constants/workspace"; +import { GLOBAL_VIEW_OPENED } from "constants/event-tracker"; const ViewTab = observer((props: { viewId: string }) => { const { viewId } = props; @@ -49,11 +50,19 @@ export const GlobalViewsHeader: React.FC = observer(() => { const { membership: { currentWorkspaceRole }, } = useUser(); + const { captureEvent } = useEventTracker(); // bring the active view to the centre of the header useEffect(() => { if (!globalViewId) return; + captureEvent(GLOBAL_VIEW_OPENED, { + view_id: globalViewId, + view_type: ["all-issues", "assigned", "created", "subscribed"].includes(globalViewId.toString()) + ? "Default" + : "Custom", + }); + const activeTabElement = document.querySelector(`#global-view-${globalViewId.toString()}`); if (activeTabElement) activeTabElement.scrollIntoView({ behavior: "smooth", inline: "center" }); diff --git a/web/components/workspace/views/modal.tsx b/web/components/workspace/views/modal.tsx index b015b4cb6..b66d555fa 100644 --- a/web/components/workspace/views/modal.tsx +++ b/web/components/workspace/views/modal.tsx @@ -3,12 +3,14 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Dialog, Transition } from "@headlessui/react"; // store hooks -import { useGlobalView } from "hooks/store"; +import { useEventTracker, useGlobalView } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { WorkspaceViewForm } from "components/workspace"; // types import { IWorkspaceView } from "@plane/types"; +// constants +import { GLOBAL_VIEW_CREATED, GLOBAL_VIEW_UPDATED } from "constants/event-tracker"; type Props = { data?: IWorkspaceView; @@ -24,6 +26,7 @@ export const CreateUpdateWorkspaceViewModal: React.FC = observer((props) const { workspaceSlug } = router.query; // store hooks const { createGlobalView, updateGlobalView } = useGlobalView(); + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); @@ -43,6 +46,11 @@ export const CreateUpdateWorkspaceViewModal: React.FC = observer((props) await createGlobalView(workspaceSlug.toString(), payloadData) .then((res) => { + captureEvent(GLOBAL_VIEW_CREATED, { + view_id: res.id, + applied_filters: res.filters, + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Success!", @@ -52,13 +60,17 @@ export const CreateUpdateWorkspaceViewModal: React.FC = observer((props) router.push(`/${workspaceSlug}/workspace-views/${res.id}`); handleClose(); }) - .catch(() => + .catch(() => { + captureEvent(GLOBAL_VIEW_CREATED, { + applied_filters: payload?.filters, + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: "View could not be created. Please try again.", - }) - ); + }); + }); }; const handleUpdateView = async (payload: Partial) => { @@ -72,7 +84,12 @@ export const CreateUpdateWorkspaceViewModal: React.FC = observer((props) }; await updateGlobalView(workspaceSlug.toString(), data.id, payloadData) - .then(() => { + .then((res) => { + captureEvent(GLOBAL_VIEW_UPDATED, { + view_id: res.id, + applied_filters: res.filters, + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Success!", @@ -80,13 +97,18 @@ export const CreateUpdateWorkspaceViewModal: React.FC = observer((props) }); handleClose(); }) - .catch(() => + .catch(() => { + captureEvent(GLOBAL_VIEW_UPDATED, { + view_id: data.id, + applied_filters: data.filters, + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: "View could not be updated. Please try again.", - }) - ); + }); + }); }; const handleFormSubmit = async (formData: Partial) => { diff --git a/web/components/workspace/views/view-list-item.tsx b/web/components/workspace/views/view-list-item.tsx index 1d9289037..ad551494b 100644 --- a/web/components/workspace/views/view-list-item.tsx +++ b/web/components/workspace/views/view-list-item.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { observer } from "mobx-react-lite"; import { Pencil, Trash2 } from "lucide-react"; // store hooks -import { useGlobalView } from "hooks/store"; +import { useEventTracker, useGlobalView } from "hooks/store"; // components import { CreateUpdateWorkspaceViewModal, DeleteGlobalViewModal } from "components/workspace"; // ui @@ -25,6 +25,7 @@ export const GlobalViewListItem: React.FC = observer((props) => { const { workspaceSlug } = router.query; // store hooks const { getViewDetailsById } = useGlobalView(); + const {setTrackElement} = useEventTracker(); // derived data const view = getViewDetailsById(viewId); @@ -59,6 +60,7 @@ export const GlobalViewListItem: React.FC = observer((props) => { onClick={(e) => { e.preventDefault(); e.stopPropagation(); + setTrackElement("List view"); setUpdateViewModal(true); }} > diff --git a/web/constants/event-tracker.ts b/web/constants/event-tracker.ts index 67f1b1034..a0bf0b5bb 100644 --- a/web/constants/event-tracker.ts +++ b/web/constants/event-tracker.ts @@ -2,26 +2,31 @@ export type IssueEventProps = { eventName: string; payload: any; updates?: any; - group?: EventGroupProps; path?: string; }; export type EventProps = { eventName: string; payload: any; - group?: EventGroupProps; }; -export type EventGroupProps = { - isGrouping?: boolean; - groupType?: string; - groupId?: string; -}; +export const getWorkspaceEventPayload = (payload: any) => ({ + workspace_id: payload.id, + created_at: payload.created_at, + updated_at: payload.updated_at, + organization_size: payload.organization_size, + first_time: payload.first_time, + state: payload.state, + element: payload.element, +}); export const getProjectEventPayload = (payload: any) => ({ workspace_id: payload.workspace_id, project_id: payload.id, identifier: payload.identifier, + project_visibility: payload.network == 2 ? "Public" : "Private", + changed_properties: payload.changed_properties, + lead_id: payload.project_lead, created_at: payload.created_at, updated_at: payload.updated_at, state: payload.state, @@ -30,26 +35,43 @@ export const getProjectEventPayload = (payload: any) => ({ export const getCycleEventPayload = (payload: any) => ({ workspace_id: payload.workspace_id, - project_id: payload.id, + project_id: payload.project, cycle_id: payload.id, created_at: payload.created_at, updated_at: payload.updated_at, start_date: payload.start_date, target_date: payload.target_date, cycle_status: payload.status, + changed_properties: payload.changed_properties, state: payload.state, element: payload.element, }); export const getModuleEventPayload = (payload: any) => ({ workspace_id: payload.workspace_id, - project_id: payload.id, + project_id: payload.project, module_id: payload.id, created_at: payload.created_at, updated_at: payload.updated_at, start_date: payload.start_date, target_date: payload.target_date, module_status: payload.status, + lead_id: payload.lead, + changed_properties: payload.changed_properties, + member_ids: payload.members, + state: payload.state, + element: payload.element, +}); + +export const getPageEventPayload = (payload: any) => ({ + workspace_id: payload.workspace_id, + project_id: payload.project, + created_at: payload.created_at, + updated_at: payload.updated_at, + access: payload.access === 0 ? "Public" : "Private", + is_locked: payload.is_locked, + archived_at: payload.archived_at, + created_by: payload.created_by, state: payload.state, element: payload.element, }); @@ -71,6 +93,7 @@ export const getIssueEventPayload = (props: IssueEventProps) => { sub_issues_count: payload.sub_issues_count, parent_id: payload.parent_id, project_id: payload.project_id, + workspace_id: payload.workspace_id, priority: payload.priority, state_id: payload.state_id, start_date: payload.start_date, @@ -82,7 +105,7 @@ export const getIssueEventPayload = (props: IssueEventProps) => { view_id: path?.includes("workspace-views") || path?.includes("views") ? path.split("/").pop() : "", }; - if (eventName === "Issue updated") { + if (eventName === ISSUE_UPDATED) { eventPayload = { ...eventPayload, ...updates, @@ -103,3 +126,99 @@ export const getIssueEventPayload = (props: IssueEventProps) => { } return eventPayload; }; + +export const getProjectStateEventPayload = (payload: any) => { + return { + workspace_id: payload.workspace_id, + project_id: payload.id, + state_id: payload.id, + created_at: payload.created_at, + updated_at: payload.updated_at, + group: payload.group, + color: payload.color, + default: payload.default, + state: payload.state, + element: payload.element, + }; +}; + +// Workspace crud Events +export const WORKSPACE_CREATED = "Workspace created"; +export const WORKSPACE_UPDATED = "Workspace updated"; +export const WORKSPACE_DELETED = "Workspace deleted"; +// Project Events +export const PROJECT_CREATED = "Project created"; +export const PROJECT_UPDATED = "Project updated"; +export const PROJECT_DELETED = "Project deleted"; +// Cycle Events +export const CYCLE_CREATED = "Cycle created"; +export const CYCLE_UPDATED = "Cycle updated"; +export const CYCLE_DELETED = "Cycle deleted"; +export const CYCLE_FAVORITED = "Cycle favorited"; +export const CYCLE_UNFAVORITED = "Cycle unfavorited"; +// Module Events +export const MODULE_CREATED = "Module created"; +export const MODULE_UPDATED = "Module updated"; +export const MODULE_DELETED = "Module deleted"; +export const MODULE_FAVORITED = "Module favorited"; +export const MODULE_UNFAVORITED = "Module unfavorited"; +export const MODULE_LINK_CREATED = "Module link created"; +export const MODULE_LINK_UPDATED = "Module link updated"; +export const MODULE_LINK_DELETED = "Module link deleted"; +// Issue Events +export const ISSUE_CREATED = "Issue created"; +export const ISSUE_UPDATED = "Issue updated"; +export const ISSUE_DELETED = "Issue deleted"; +export const ISSUE_OPENED = "Issue opened"; +// Project State Events +export const STATE_CREATED = "State created"; +export const STATE_UPDATED = "State updated"; +export const STATE_DELETED = "State deleted"; +// Project Page Events +export const PAGE_CREATED = "Page created"; +export const PAGE_UPDATED = "Page updated"; +export const PAGE_DELETED = "Page deleted"; +// Member Events +export const MEMBER_INVITED = "Member invited"; +export const MEMBER_ACCEPTED = "Member accepted"; +export const PROJECT_MEMBER_ADDED = "Project member added"; +export const PROJECT_MEMBER_LEAVE = "Project member leave"; +export const WORKSPACE_MEMBER_lEAVE = "Workspace member leave"; +// Sign-in & Sign-up Events +export const NAVIGATE_TO_SIGNUP = "Navigate to sign-up page"; +export const NAVIGATE_TO_SIGNIN = "Navigate to sign-in page"; +export const CODE_VERIFIED = "Code verified"; +export const SETUP_PASSWORD = "Password setup"; +export const PASSWORD_CREATE_SELECTED = "Password created"; +export const PASSWORD_CREATE_SKIPPED = "Skipped to setup"; +export const SIGN_IN_WITH_PASSWORD = "Sign in with password"; +export const FORGOT_PASSWORD = "Forgot password clicked"; +export const FORGOT_PASS_LINK = "Forgot password link generated"; +export const NEW_PASS_CREATED = "New password created"; +// Onboarding Events +export const USER_DETAILS = "User details added"; +export const USER_ONBOARDING_COMPLETED = "User onboarding completed"; +// Product Tour Events +export const PRODUCT_TOUR_STARTED = "Product tour started"; +export const PRODUCT_TOUR_COMPLETED = "Product tour completed"; +export const PRODUCT_TOUR_SKIPPED = "Product tour skipped"; +// Dashboard Events +export const CHANGELOG_REDIRECTED = "Changelog redirected"; +export const GITHUB_REDIRECTED = "Github redirected"; +// Sidebar Events +export const SIDEBAR_CLICKED = "Sidenav clicked"; +// Global View Events +export const GLOBAL_VIEW_CREATED = "Global view created"; +export const GLOBAL_VIEW_UPDATED = "Global view updated"; +export const GLOBAL_VIEW_DELETED = "Global view deleted"; +export const GLOBAL_VIEW_OPENED = "Global view opened"; +// Notification Events +export const NOTIFICATION_ARCHIVED = "Notification archived"; +export const NOTIFICATION_SNOOZED = "Notification snoozed"; +export const NOTIFICATION_READ = "Notification marked read"; +export const UNREAD_NOTIFICATIONS = "Unread notifications viewed"; +export const NOTIFICATIONS_READ = "All notifications marked read"; +export const SNOOZED_NOTIFICATIONS= "Snoozed notifications viewed"; +export const ARCHIVED_NOTIFICATIONS = "Archived notifications viewed"; +// Groups +export const GROUP_WORKSPACE = "Workspace_metrics"; diff --git a/web/constants/issue.ts b/web/constants/issue.ts index 57dff280e..5b6ce8187 100644 --- a/web/constants/issue.ts +++ b/web/constants/issue.ts @@ -412,6 +412,13 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { }, }; +export enum EIssueListRow { + HEADER = "HEADER", + ISSUE = "ISSUE", + NO_ISSUES = "NO_ISSUES", + QUICK_ADD = "QUICK_ADD", +} + export const getValueFromObject = (object: Object, key: string): string | number | boolean | null => { const keys = key ? key.split(".") : []; @@ -442,4 +449,4 @@ export const groupReactionEmojis = (reactions: any) => { } return _groupedEmojis; -}; +}; \ No newline at end of file diff --git a/web/helpers/dashboard.helper.ts b/web/helpers/dashboard.helper.ts index 8003f15e3..90319a90b 100644 --- a/web/helpers/dashboard.helper.ts +++ b/web/helpers/dashboard.helper.ts @@ -4,6 +4,10 @@ import { renderFormattedPayloadDate } from "./date-time.helper"; // types import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types"; +/** + * @description returns date range based on the duration filter + * @param duration + */ export const getCustomDates = (duration: TDurationFilterOptions): string => { const today = new Date(); let firstDay, lastDay; @@ -30,6 +34,10 @@ export const getCustomDates = (duration: TDurationFilterOptions): string => { } }; +/** + * @description returns redirection filters for the issues list + * @param type + */ export const getRedirectionFilters = (type: TIssuesListTypes): string => { const today = renderFormattedPayloadDate(new Date()); @@ -44,3 +52,20 @@ export const getRedirectionFilters = (type: TIssuesListTypes): string => { return filterParams; }; + +/** + * @description returns the tab key based on the duration filter + * @param duration + * @param tab + */ +export const getTabKey = (duration: TDurationFilterOptions, tab: TIssuesListTypes | undefined): TIssuesListTypes => { + if (!tab) return "completed"; + + if (tab === "completed") return tab; + + if (duration === "none") return "pending"; + else { + if (["upcoming", "overdue"].includes(tab)) return tab; + else return "upcoming"; + } +}; diff --git a/web/layouts/settings-layout/profile/preferences/layout.tsx b/web/layouts/settings-layout/profile/preferences/layout.tsx index 9d17350a9..b25935f4e 100644 --- a/web/layouts/settings-layout/profile/preferences/layout.tsx +++ b/web/layouts/settings-layout/profile/preferences/layout.tsx @@ -2,6 +2,11 @@ import { FC, ReactNode } from "react"; // layout import { ProfileSettingsLayout } from "layouts/settings-layout"; import { ProfilePreferenceSettingsSidebar } from "./sidebar"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { CustomMenu } from "@plane/ui"; +import { ChevronDown } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; interface IProfilePreferenceSettingsLayout { children: ReactNode; @@ -10,9 +15,57 @@ interface IProfilePreferenceSettingsLayout { export const ProfilePreferenceSettingsLayout: FC = (props) => { const { children, header } = props; + const router = useRouter(); + + const showMenuItem = () => { + const item = router.asPath.split('/'); + let splittedItem = item[item.length - 1]; + splittedItem = splittedItem.replace(splittedItem[0], splittedItem[0].toUpperCase()); + console.log(splittedItem); + return splittedItem; + } + + const profilePreferenceLinks: Array<{ + label: string; + href: string; + }> = [ + { + label: "Theme", + href: `/profile/preferences/theme`, + }, + { + label: "Email", + href: `/profile/preferences/email`, + }, + ]; return ( - + + + + {showMenuItem()} + +
+ } + customButtonClassName="flex flex-grow justify-start text-custom-text-200 text-sm" + > + <> + {profilePreferenceLinks.map((link) => ( + + {link.label} + + ))} + +
+ }>
diff --git a/web/layouts/settings-layout/profile/preferences/sidebar.tsx b/web/layouts/settings-layout/profile/preferences/sidebar.tsx index d1eec1233..7f43f3cad 100644 --- a/web/layouts/settings-layout/profile/preferences/sidebar.tsx +++ b/web/layouts/settings-layout/profile/preferences/sidebar.tsx @@ -9,28 +9,27 @@ export const ProfilePreferenceSettingsSidebar = () => { label: string; href: string; }> = [ - { - label: "Theme", - href: `/profile/preferences/theme`, - }, - { - label: "Email", - href: `/profile/preferences/email`, - }, - ]; + { + label: "Theme", + href: `/profile/preferences/theme`, + }, + { + label: "Email", + href: `/profile/preferences/email`, + }, + ]; return ( -
+
Preference
{profilePreferenceLinks.map((link) => (
{link.label}
diff --git a/web/layouts/settings-layout/profile/sidebar.tsx b/web/layouts/settings-layout/profile/sidebar.tsx index 0a97b3364..4b8a1b854 100644 --- a/web/layouts/settings-layout/profile/sidebar.tsx +++ b/web/layouts/settings-layout/profile/sidebar.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { mutate } from "swr"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -12,6 +12,7 @@ import useToast from "hooks/use-toast"; import { Tooltip } from "@plane/ui"; // constants import { PROFILE_ACTION_LINKS } from "constants/profile"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; const WORKSPACE_ACTION_LINKS = [ { @@ -52,6 +53,35 @@ export const ProfileLayoutSidebar = observer(() => { currentUserSettings?.workspace?.fallback_workspace_slug || ""; + const ref = useRef(null); + + useOutsideClickDetector(ref, () => { + if (sidebarCollapsed === false) { + if (window.innerWidth < 768) { + toggleSidebar(); + } + } + }); + + useEffect(() => { + const handleResize = () => { + if (window.innerWidth <= 768) { + toggleSidebar(true); + } + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [toggleSidebar]); + + const handleItemClick = () => { + if (window.innerWidth < 768) { + toggleSidebar(); + } + }; + const handleSignOut = async () => { setIsSigningOut(true); @@ -73,16 +103,18 @@ export const ProfileLayoutSidebar = observer(() => { return (
-
+
@@ -101,14 +133,13 @@ export const ProfileLayoutSidebar = observer(() => { if (link.key === "change-password" && currentUser?.is_password_autoset) return null; return ( - +
{} {!sidebarCollapsed && link.label} @@ -129,19 +160,17 @@ export const ProfileLayoutSidebar = observer(() => { {workspace?.logo && workspace.logo !== "" ? ( { )}
{WORKSPACE_ACTION_LINKS.map((link) => ( - +
{} {!sidebarCollapsed && link.label} @@ -180,9 +208,8 @@ export const ProfileLayoutSidebar = observer(() => {
)} diff --git a/web/pages/accounts/forgot-password.tsx b/web/pages/accounts/forgot-password.tsx index 8d3c4cd28..07fa86045 100644 --- a/web/pages/accounts/forgot-password.tsx +++ b/web/pages/accounts/forgot-password.tsx @@ -7,6 +7,7 @@ import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; import useTimer from "hooks/use-timer"; +import { useEventTracker } from "hooks/store"; // layouts import DefaultLayout from "layouts/default-layout"; // components @@ -19,6 +20,7 @@ import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; import { checkEmailValidity } from "helpers/string.helper"; // type import { NextPageWithLayout } from "lib/types"; +import { FORGOT_PASS_LINK } from "constants/event-tracker"; type TForgotPasswordFormValues = { email: string; @@ -35,6 +37,8 @@ const ForgotPasswordPage: NextPageWithLayout = () => { // router const router = useRouter(); const { email } = router.query; + // store hooks + const { captureEvent } = useEventTracker(); // toast const { setToastAlert } = useToast(); // timer @@ -57,6 +61,9 @@ const ForgotPasswordPage: NextPageWithLayout = () => { email: formData.email, }) .then(() => { + captureEvent(FORGOT_PASS_LINK, { + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Email sent", @@ -65,13 +72,16 @@ const ForgotPasswordPage: NextPageWithLayout = () => { }); setResendCodeTimer(30); }) - .catch((err) => + .catch((err) => { + captureEvent(FORGOT_PASS_LINK, { + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; return ( diff --git a/web/pages/accounts/reset-password.tsx b/web/pages/accounts/reset-password.tsx index 9854ec5bb..c4258f39e 100644 --- a/web/pages/accounts/reset-password.tsx +++ b/web/pages/accounts/reset-password.tsx @@ -7,6 +7,7 @@ import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; import useSignInRedirection from "hooks/use-sign-in-redirection"; +import { useEventTracker } from "hooks/store"; // layouts import DefaultLayout from "layouts/default-layout"; // components @@ -21,6 +22,8 @@ import { checkEmailValidity } from "helpers/string.helper"; import { NextPageWithLayout } from "lib/types"; // icons import { Eye, EyeOff } from "lucide-react"; +// constants +import { NEW_PASS_CREATED } from "constants/event-tracker"; type TResetPasswordFormValues = { email: string; @@ -41,6 +44,8 @@ const ResetPasswordPage: NextPageWithLayout = () => { const { uidb64, token, email } = router.query; // states const [showPassword, setShowPassword] = useState(false); + // store hooks + const { captureEvent } = useEventTracker(); // toast const { setToastAlert } = useToast(); // sign in redirection hook @@ -66,14 +71,22 @@ const ResetPasswordPage: NextPageWithLayout = () => { await authService .resetPassword(uidb64.toString(), token.toString(), payload) - .then(() => handleRedirection()) - .catch((err) => + .then(() => { + captureEvent(NEW_PASS_CREATED, { + state: "SUCCESS", + }); + handleRedirection(); + }) + .catch((err) => { + captureEvent(NEW_PASS_CREATED, { + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; return ( diff --git a/web/pages/invitations/index.tsx b/web/pages/invitations/index.tsx index 1d8c3e774..26ced2010 100644 --- a/web/pages/invitations/index.tsx +++ b/web/pages/invitations/index.tsx @@ -23,11 +23,13 @@ import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-l import emptyInvitation from "public/empty-state/invitation.svg"; // helpers import { truncateText } from "helpers/string.helper"; +import { getUserRole } from "helpers/user.helper"; // types import { NextPageWithLayout } from "lib/types"; import type { IWorkspaceMemberInvitation } from "@plane/types"; // constants import { ROLE } from "constants/workspace"; +import { MEMBER_ACCEPTED } from "constants/event-tracker"; // components import { EmptyState } from "components/common"; @@ -40,7 +42,7 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { const [invitationsRespond, setInvitationsRespond] = useState([]); const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); // store hooks - const { captureEvent } = useEventTracker(); + const { captureEvent, joinWorkspaceMetricGroup } = useEventTracker(); const { currentUser, currentUserSettings } = useUser(); // router const router = useRouter(); @@ -81,11 +83,16 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { .then((res) => { mutate("USER_WORKSPACES"); const firstInviteId = invitationsRespond[0]; + const invitation = invitations?.find((i) => i.id === firstInviteId); const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace; - captureEvent("Member accepted", { - ...res, - state: "SUCCESS", + joinWorkspaceMetricGroup(redirectWorkspace?.id); + captureEvent(MEMBER_ACCEPTED, { + member_id: invitation?.id, + role: getUserRole(invitation?.role!), + project_id: undefined, accepted_from: "App", + state: "SUCCESS", + element: "Workspace invitations page", }); userService .updateUser({ last_workspace_id: redirectWorkspace?.id }) @@ -103,6 +110,12 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { }); }) .catch(() => { + captureEvent(MEMBER_ACCEPTED, { + project_id: undefined, + accepted_from: "App", + state: "FAILED", + element: "Workspace invitations page", + }); setToastAlert({ type: "error", title: "Error!", diff --git a/web/pages/onboarding/index.tsx b/web/pages/onboarding/index.tsx index 5a5911fca..99886156d 100644 --- a/web/pages/onboarding/index.tsx +++ b/web/pages/onboarding/index.tsx @@ -24,6 +24,8 @@ import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; // types import { IUser, TOnboardingSteps } from "@plane/types"; import { NextPageWithLayout } from "lib/types"; +// constants +import { USER_ONBOARDING_COMPLETED } from "constants/event-tracker"; // services const workspaceService = new WorkspaceService(); @@ -79,7 +81,7 @@ const OnboardingPage: NextPageWithLayout = observer(() => { await updateUserOnBoard() .then(() => { - captureEvent("User onboarding completed", { + captureEvent(USER_ONBOARDING_COMPLETED, { user_role: user.role, email: user.email, user_id: user.id, diff --git a/web/pages/profile/activity.tsx b/web/pages/profile/activity.tsx index e76473cf4..d0c83ffdb 100644 --- a/web/pages/profile/activity.tsx +++ b/web/pages/profile/activity.tsx @@ -21,6 +21,7 @@ import { USER_ACTIVITY } from "constants/fetch-keys"; import { calculateTimeAgo } from "helpers/date-time.helper"; // type import { NextPageWithLayout } from "lib/types"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; const userService = new UserService(); @@ -30,8 +31,10 @@ const ProfileActivityPage: NextPageWithLayout = observer(() => { const { currentUser } = useUser(); return ( -
-
+ +
+
+

Activity

{userActivity ? ( @@ -94,12 +97,12 @@ const ProfileActivityPage: NextPageWithLayout = observer(() => { const message = activityItem.verb === "created" && - activityItem.field !== "cycles" && - activityItem.field !== "modules" && - activityItem.field !== "attachment" && - activityItem.field !== "link" && - activityItem.field !== "estimate" && - !activityItem.field ? ( + activityItem.field !== "cycles" && + activityItem.field !== "modules" && + activityItem.field !== "attachment" && + activityItem.field !== "link" && + activityItem.field !== "estimate" && + !activityItem.field ? ( created @@ -187,6 +190,7 @@ const ProfileActivityPage: NextPageWithLayout = observer(() => { )}
+ ); }); diff --git a/web/pages/profile/change-password.tsx b/web/pages/profile/change-password.tsx index 59bc657c7..4641837fd 100644 --- a/web/pages/profile/change-password.tsx +++ b/web/pages/profile/change-password.tsx @@ -14,6 +14,7 @@ import { ProfileSettingsLayout } from "layouts/settings-layout"; import { Button, Input, Spinner } from "@plane/ui"; // types import { NextPageWithLayout } from "lib/types"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; interface FormValues { old_password: string; @@ -86,6 +87,10 @@ const ChangePasswordPage: NextPageWithLayout = observer(() => { ); return ( +
+
+ +
{
+
); }); diff --git a/web/pages/profile/index.tsx b/web/pages/profile/index.tsx index 294ef3574..655d6a4bd 100644 --- a/web/pages/profile/index.tsx +++ b/web/pages/profile/index.tsx @@ -23,6 +23,7 @@ import type { NextPageWithLayout } from "lib/types"; // constants import { USER_ROLES } from "constants/workspace"; import { TIME_ZONES } from "constants/timezones"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; const defaultValues: Partial = { avatar: "", @@ -56,7 +57,7 @@ const ProfileSettingsPage: NextPageWithLayout = observer(() => { // store hooks const { currentUser: myProfile, updateCurrentUser, currentUserLoader } = useUser(); // custom hooks - const {} = useUserAuth({ user: myProfile, isLoading: currentUserLoader }); + const { } = useUserAuth({ user: myProfile, isLoading: currentUserLoader }); useEffect(() => { reset({ ...defaultValues, ...myProfile }); @@ -136,304 +137,310 @@ const ProfileSettingsPage: NextPageWithLayout = observer(() => { return ( <> - ( - setIsImageUploadModalOpen(false)} - isRemoving={isRemoving} - handleDelete={() => handleDelete(myProfile?.avatar, true)} - onSuccess={(url) => { - onChange(url); - handleSubmit(onSubmit)(); - setIsImageUploadModalOpen(false); - }} - value={value && value.trim() !== "" ? value : null} - /> - )} - /> - setDeactivateAccountModal(false)} /> -
-
-
-
- {myProfile?.first_name +
+ +
+
+ ( + setIsImageUploadModalOpen(false)} + isRemoving={isRemoving} + handleDelete={() => handleDelete(myProfile?.avatar, true)} + onSuccess={(url) => { + onChange(url); + handleSubmit(onSubmit)(); + setIsImageUploadModalOpen(false); + }} + value={value && value.trim() !== "" ? value : null} /> -
-
-
- +
+
+
+ +
+ ( + onChange(imageUrl)} + control={control} + value={value ?? "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"} + /> )} - + />
-
-
- ( - onChange(imageUrl)} - control={control} - value={value ?? "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"} - /> - )} - /> -
-
+
+
+
+ {`${watch("first_name")} ${watch("last_name")}`} +
+ {watch("email")} +
-
-
-
- {`${watch("first_name")} ${watch("last_name")}`} -
- {watch("email")} -
- - {/* + {/* Activity Overview */} -
+
-
-
-

- First name* -

- ( - +
+

+ First name* +

+ ( + + )} /> - )} - /> - {errors.first_name && Please enter first name} -
- -
-

Last name

- - ( - - )} - /> -
- -
-

- Email* -

- ( - - )} - /> -
- -
-

- Role* -

- ( - - {USER_ROLES.map((item) => ( - - {item.label} - - ))} - - )} - /> - {errors.role && Please select a role} -
- -
-

- Display name* -

- { - if (value.trim().length < 1) return "Display name can't be empty."; - - if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces."; - - if (value.replace(/\s/g, "").length < 1) - return "Display name must be at least 1 characters long."; - - if (value.replace(/\s/g, "").length > 20) - return "Display name must be less than 20 characters long."; - - return true; - }, - }} - render={({ field: { value, onChange, ref } }) => ( - - )} - /> - {errors.display_name && Please enter display name} -
- -
-

- Timezone* -

- - ( - t.value === value)?.label ?? value : "Select a timezone"} - options={timeZoneOptions} - onChange={onChange} - optionsClassName="w-full" - buttonClassName={errors.user_timezone ? "border-red-500" : "border-none"} - className="rounded-md border-[0.5px] !border-custom-border-200" - input - /> - )} - /> - {errors.role && Please select a time zone} -
- -
- -
-
-
-
- - {({ open }) => ( - <> - - Deactivate account - - - - -
- - The danger zone of the profile page is a critical area that requires careful consideration and - attention. When deactivating an account, all of the data and resources within that account will be - permanently removed and cannot be recovered. - -
- -
+ {errors.first_name && Please enter first name}
-
-
- - )} -
+ +
+

Last name

+ + ( + + )} + /> +
+ +
+

+ Email* +

+ ( + + )} + /> +
+ +
+

+ Role* +

+ ( + + {USER_ROLES.map((item) => ( + + {item.label} + + ))} + + )} + /> + {errors.role && Please select a role} +
+ +
+

+ Display name* +

+ { + if (value.trim().length < 1) return "Display name can't be empty."; + + if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces."; + + if (value.replace(/\s/g, "").length < 1) + return "Display name must be at least 1 characters long."; + + if (value.replace(/\s/g, "").length > 20) + return "Display name must be less than 20 characters long."; + + return true; + }, + }} + render={({ field: { value, onChange, ref } }) => ( + + )} + /> + {errors.display_name && Please enter display name} +
+ +
+

+ Timezone* +

+ + ( + t.value === value)?.label ?? value : "Select a timezone"} + options={timeZoneOptions} + onChange={onChange} + optionsClassName="w-full" + buttonClassName={errors.user_timezone ? "border-red-500" : "border-none"} + className="rounded-md border-[0.5px] !border-custom-border-200" + input + /> + )} + /> + {errors.role && Please select a time zone} +
+ +
+ +
+
+
+ + + {({ open }) => ( + <> + + Deactivate account + + + + +
+ + The danger zone of the profile page is a critical area that requires careful consideration and + attention. When deactivating an account, all of the data and resources within that account will be + permanently removed and cannot be recovered. + +
+ +
+
+
+
+ + )} +
+
+
); diff --git a/web/pages/profile/preferences/email.tsx b/web/pages/profile/preferences/email.tsx index 714d8b555..7db6df113 100644 --- a/web/pages/profile/preferences/email.tsx +++ b/web/pages/profile/preferences/email.tsx @@ -2,6 +2,8 @@ import { ReactElement } from "react"; import useSWR from "swr"; // layouts import { ProfilePreferenceSettingsLayout } from "layouts/settings-layout/profile/preferences"; +// ui +import { Loader } from "@plane/ui"; // components import { EmailNotificationForm } from "components/profile/preferences"; // services @@ -14,10 +16,20 @@ const userService = new UserService(); const ProfilePreferencesThemePage: NextPageWithLayout = () => { // fetching user email notification settings - const { data } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => + const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => userService.currentUserEmailNotificationSettings() ); + if (isLoading) { + return ( + + + + + + ); + } + if (!data) { return null; } diff --git a/web/pages/profile/preferences/theme.tsx b/web/pages/profile/preferences/theme.tsx index 51386bc29..0885ff6c8 100644 --- a/web/pages/profile/preferences/theme.tsx +++ b/web/pages/profile/preferences/theme.tsx @@ -48,7 +48,7 @@ const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => { return ( <> {currentUser ? ( -
+

Preferences

diff --git a/web/store/application/theme.store.ts b/web/store/application/theme.store.ts index 1c6f792eb..f264c175d 100644 --- a/web/store/application/theme.store.ts +++ b/web/store/application/theme.store.ts @@ -7,15 +7,21 @@ export interface IThemeStore { // observables theme: string | null; sidebarCollapsed: boolean | undefined; + profileSidebarCollapsed: boolean | undefined; + workspaceAnalyticsSidebarCollapsed: boolean | undefined; // actions toggleSidebar: (collapsed?: boolean) => void; setTheme: (theme: any) => void; + toggleProfileSidebar: (collapsed?: boolean) => void; + toggleWorkspaceAnalyticsSidebar: (collapsed?: boolean) => void; } export class ThemeStore implements IThemeStore { // observables sidebarCollapsed: boolean | undefined = undefined; theme: string | null = null; + profileSidebarCollapsed: boolean | undefined = undefined; + workspaceAnalyticsSidebarCollapsed: boolean | undefined = undefined; // root store rootStore; @@ -24,9 +30,13 @@ export class ThemeStore implements IThemeStore { // observable sidebarCollapsed: observable.ref, theme: observable.ref, + profileSidebarCollapsed: observable.ref, + workspaceAnalyticsSidebarCollapsed: observable.ref, // action toggleSidebar: action, setTheme: action, + toggleProfileSidebar: action, + toggleWorkspaceAnalyticsSidebar: action // computed }); // root store @@ -46,6 +56,32 @@ export class ThemeStore implements IThemeStore { localStorage.setItem("app_sidebar_collapsed", this.sidebarCollapsed.toString()); }; + /** + * Toggle the profile sidebar collapsed state + * @param collapsed + */ + toggleProfileSidebar = (collapsed?: boolean) => { + if (collapsed === undefined) { + this.profileSidebarCollapsed = !this.profileSidebarCollapsed; + } else { + this.profileSidebarCollapsed = collapsed; + } + localStorage.setItem("profile_sidebar_collapsed", this.profileSidebarCollapsed.toString()); + }; + + /** + * Toggle the profile sidebar collapsed state + * @param collapsed + */ + toggleWorkspaceAnalyticsSidebar = (collapsed?: boolean) => { + if (collapsed === undefined) { + this.workspaceAnalyticsSidebarCollapsed = !this.workspaceAnalyticsSidebarCollapsed; + } else { + this.workspaceAnalyticsSidebarCollapsed = collapsed; + } + localStorage.setItem("workspace_analytics_sidebar_collapsed", this.workspaceAnalyticsSidebarCollapsed.toString()); + }; + /** * Sets the user theme and applies it to the platform * @param _theme diff --git a/web/store/event-tracker.store.ts b/web/store/event-tracker.store.ts index 89e279c40..744ad44fb 100644 --- a/web/store/event-tracker.store.ts +++ b/web/store/event-tracker.store.ts @@ -3,39 +3,49 @@ import posthog from "posthog-js"; // stores import { RootStore } from "./root.store"; import { - EventGroupProps, + GROUP_WORKSPACE, + WORKSPACE_CREATED, EventProps, IssueEventProps, getCycleEventPayload, getIssueEventPayload, getModuleEventPayload, getProjectEventPayload, + getProjectStateEventPayload, + getWorkspaceEventPayload, + getPageEventPayload, } from "constants/event-tracker"; export interface IEventTrackerStore { // properties - trackElement: string; + trackElement: string | undefined; // computed - getRequiredPayload: any; + getRequiredProperties: any; // actions + resetSession: () => void; setTrackElement: (element: string) => void; - captureEvent: (eventName: string, payload: object | [] | null, group?: EventGroupProps) => void; + captureEvent: (eventName: string, payload?: any) => void; + joinWorkspaceMetricGroup: (workspaceId?: string) => void; + captureWorkspaceEvent: (props: EventProps) => void; captureProjectEvent: (props: EventProps) => void; captureCycleEvent: (props: EventProps) => void; captureModuleEvent: (props: EventProps) => void; + capturePageEvent: (props: EventProps) => void; captureIssueEvent: (props: IssueEventProps) => void; + captureProjectStateEvent: (props: EventProps) => void; } export class EventTrackerStore implements IEventTrackerStore { - trackElement: string = ""; + trackElement: string | undefined = undefined; rootStore; constructor(_rootStore: RootStore) { makeObservable(this, { // properties trackElement: observable, // computed - getRequiredPayload: computed, + getRequiredProperties: computed, // actions + resetSession: action, setTrackElement: action, captureEvent: action, captureProjectEvent: action, @@ -48,12 +58,12 @@ export class EventTrackerStore implements IEventTrackerStore { /** * @description: Returns the necessary property for the event tracking */ - get getRequiredPayload() { + get getRequiredProperties() { const currentWorkspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; const currentProjectDetails = this.rootStore.projectRoot.project.currentProjectDetails; return { - workspace_id: currentWorkspaceDetails?.id ?? "", - project_id: currentProjectDetails?.id ?? "", + workspace_id: currentWorkspaceDetails?.id, + project_id: currentProjectDetails?.id, }; } @@ -61,42 +71,74 @@ export class EventTrackerStore implements IEventTrackerStore { * @description: Set the trigger point of event. * @param {string} element */ - setTrackElement = (element: string) => { + setTrackElement = (element?: string) => { this.trackElement = element; }; - postHogGroup = (group: EventGroupProps) => { - if (group && group!.isGrouping === true) { - posthog?.group(group!.groupType!, group!.groupId!, { - date: new Date(), - workspace_id: group!.groupId, - }); - } + /** + * @description: Reset the session. + */ + resetSession = () => { + posthog?.reset(); }; - captureEvent = (eventName: string, payload: object | [] | null) => { - posthog?.capture(eventName, { - ...payload, - element: this.trackElement ?? "", + /** + * @description: Creates the workspace metric group. + * @param {string} userEmail + * @param {string} workspaceId + */ + joinWorkspaceMetricGroup = (workspaceId?: string) => { + if (!workspaceId) return; + posthog?.group(GROUP_WORKSPACE, workspaceId, { + date: new Date().toDateString(), + workspace_id: workspaceId, }); }; + /** + * @description: Captures the event. + * @param {string} eventName + * @param {any} payload + */ + captureEvent = (eventName: string, payload?: any) => { + posthog?.capture(eventName, { + ...this.getRequiredProperties, + ...payload, + element: payload?.element ?? this.trackElement, + }); + this.setTrackElement(undefined); + }; + + /** + * @description: Captures the workspace crud related events. + * @param {EventProps} props + */ + captureWorkspaceEvent = (props: EventProps) => { + const { eventName, payload } = props; + if (eventName === WORKSPACE_CREATED && payload.state == "SUCCESS") { + this.joinWorkspaceMetricGroup(payload.id); + } + const eventPayload: any = getWorkspaceEventPayload({ + ...payload, + element: payload.element ?? this.trackElement, + }); + posthog?.capture(eventName, eventPayload); + this.setTrackElement(undefined); + }; + /** * @description: Captures the project related events. * @param {EventProps} props */ captureProjectEvent = (props: EventProps) => { - const { eventName, payload, group } = props; - if (group) { - this.postHogGroup(group); - } + const { eventName, payload } = props; const eventPayload: any = getProjectEventPayload({ - ...this.getRequiredPayload, + ...this.getRequiredProperties, ...payload, element: payload.element ?? this.trackElement, }); posthog?.capture(eventName, eventPayload); - this.setTrackElement(""); + this.setTrackElement(undefined); }; /** @@ -104,17 +146,14 @@ export class EventTrackerStore implements IEventTrackerStore { * @param {EventProps} props */ captureCycleEvent = (props: EventProps) => { - const { eventName, payload, group } = props; - if (group) { - this.postHogGroup(group); - } + const { eventName, payload } = props; const eventPayload: any = getCycleEventPayload({ - ...this.getRequiredPayload, + ...this.getRequiredProperties, ...payload, element: payload.element ?? this.trackElement, }); posthog?.capture(eventName, eventPayload); - this.setTrackElement(""); + this.setTrackElement(undefined); }; /** @@ -122,17 +161,29 @@ export class EventTrackerStore implements IEventTrackerStore { * @param {EventProps} props */ captureModuleEvent = (props: EventProps) => { - const { eventName, payload, group } = props; - if (group) { - this.postHogGroup(group); - } + const { eventName, payload } = props; const eventPayload: any = getModuleEventPayload({ - ...this.getRequiredPayload, + ...this.getRequiredProperties, ...payload, element: payload.element ?? this.trackElement, }); posthog?.capture(eventName, eventPayload); - this.setTrackElement(""); + this.setTrackElement(undefined); + }; + + /** + * @description: Captures the project pages related events. + * @param {EventProps} props + */ + capturePageEvent = (props: EventProps) => { + const { eventName, payload } = props; + const eventPayload: any = getPageEventPayload({ + ...this.getRequiredProperties, + ...payload, + element: payload.element ?? this.trackElement, + }); + posthog?.capture(eventName, eventPayload); + this.setTrackElement(undefined); }; /** @@ -140,16 +191,29 @@ export class EventTrackerStore implements IEventTrackerStore { * @param {IssueEventProps} props */ captureIssueEvent = (props: IssueEventProps) => { - const { eventName, payload, group } = props; - if (group) { - this.postHogGroup(group); - } + const { eventName, payload } = props; const eventPayload: any = { ...getIssueEventPayload(props), - ...this.getRequiredPayload, + ...this.getRequiredProperties, state_group: this.rootStore.state.getStateById(payload.state_id)?.group ?? "", element: payload.element ?? this.trackElement, }; posthog?.capture(eventName, eventPayload); + this.setTrackElement(undefined); + }; + + /** + * @description: Captures the issue related events. + * @param {IssueEventProps} props + */ + captureProjectStateEvent = (props: EventProps) => { + const { eventName, payload } = props; + const eventPayload: any = getProjectStateEventPayload({ + ...this.getRequiredProperties, + ...payload, + element: payload.element ?? this.trackElement, + }); + posthog?.capture(eventName, eventPayload); + this.setTrackElement(undefined); }; } diff --git a/web/store/issue/helpers/issue-helper.store.ts b/web/store/issue/helpers/issue-helper.store.ts index 5fdf0df82..ff5dba9dd 100644 --- a/web/store/issue/helpers/issue-helper.store.ts +++ b/web/store/issue/helpers/issue-helper.store.ts @@ -1,7 +1,7 @@ -import sortBy from "lodash/sortBy"; +import orderBy from "lodash/orderBy"; import get from "lodash/get"; import indexOf from "lodash/indexOf"; -import reverse from "lodash/reverse"; +import isEmpty from "lodash/isEmpty"; import values from "lodash/values"; // types import { TIssue, TIssueMap, TIssueGroupByOptions, TIssueOrderByOptions } from "@plane/types"; @@ -144,98 +144,189 @@ export class IssueHelperStore implements TIssueHelperStore { issueDisplayFiltersDefaultData = (groupBy: string | null): string[] => { switch (groupBy) { case "state": - return this.rootStore?.states || []; + return Object.keys(this.rootStore?.stateMap || {}); case "state_detail.group": return Object.keys(STATE_GROUPS); case "priority": return ISSUE_PRIORITIES.map((i) => i.key); case "labels": - return this.rootStore?.labels || []; + return Object.keys(this.rootStore?.labelMap || {}); case "created_by": - return this.rootStore?.members || []; + return Object.keys(this.rootStore?.workSpaceMemberRolesMap || {}); case "assignees": - return this.rootStore?.members || []; + return Object.keys(this.rootStore?.workSpaceMemberRolesMap || {}); case "project": - return this.rootStore?.projects || []; + return Object.keys(this.rootStore?.projectMap || {}); default: return []; } }; + /** + * This Method is used to get data of the issue based on the ids of the data for states, labels adn assignees + * @param dataType what type of data is being sent + * @param dataIds id/ids of the data that is to be populated + * @param order ascending or descending for arrays of data + * @returns string | string[] of sortable fields to be used for sorting + */ + populateIssueDataForSorting( + dataType: "state_id" | "label_ids" | "assignee_ids", + dataIds: string | string[] | null | undefined, + order?: "asc" | "desc" + ) { + if (!dataIds) return; + + const dataValues: string[] = []; + const isDataIdsArray = Array.isArray(dataIds); + const dataIdsArray = isDataIdsArray ? dataIds : [dataIds]; + + switch (dataType) { + case "state_id": + const stateMap = this.rootStore?.stateMap; + if (!stateMap) break; + for (const dataId of dataIdsArray) { + const state = stateMap[dataId]; + if (state && state.name) dataValues.push(state.name.toLocaleLowerCase()); + } + break; + case "label_ids": + const labelMap = this.rootStore?.labelMap; + if (!labelMap) break; + for (const dataId of dataIdsArray) { + const label = labelMap[dataId]; + if (label && label.name) dataValues.push(label.name.toLocaleLowerCase()); + } + break; + case "assignee_ids": + const memberMap = this.rootStore?.memberMap; + if (!memberMap) break; + for (const dataId of dataIdsArray) { + const member = memberMap[dataId]; + if (memberMap && member.first_name) dataValues.push(member.first_name.toLocaleLowerCase()); + } + break; + } + + return isDataIdsArray ? (order ? orderBy(dataValues, undefined, [order]) : dataValues) : dataValues[0]; + } + + /** + * This Method is mainly used to filter out empty values in the begining + * @param key key of the value that is to be checked if empty + * @param object any object in which the key's value is to be checked + * @returns 1 if emoty, 0 if not empty + */ + getSortOrderToFilterEmptyValues(key: string, object: any) { + const value = object?.[key]; + + if (typeof value !== "number" && isEmpty(value)) return 1; + + return 0; + } + issuesSortWithOrderBy = (issueObject: TIssueMap, key: Partial): TIssue[] => { let array = values(issueObject); - array = reverse(sortBy(array, "created_at")); + array = orderBy(array, "created_at"); + switch (key) { case "sort_order": - return sortBy(array, "sort_order"); - + return orderBy(array, "sort_order"); case "state__name": - return reverse(sortBy(array, "state")); + return orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue["state_id"])); case "-state__name": - return sortBy(array, "state"); - + return orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue["state_id"]), ["desc"]); // dates case "created_at": - return sortBy(array, "created_at"); + return orderBy(array, "created_at"); case "-created_at": - return reverse(sortBy(array, "created_at")); - + return orderBy(array, "created_at", ["desc"]); case "updated_at": - return sortBy(array, "updated_at"); + return orderBy(array, "updated_at"); case "-updated_at": - return reverse(sortBy(array, "updated_at")); - + return orderBy(array, "updated_at", ["desc"]); case "start_date": - return sortBy(array, "start_date"); + return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"]); //preferring sorting based on empty values to always keep the empty values below case "-start_date": - return reverse(sortBy(array, "start_date")); + return orderBy( + array, + [this.getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ); case "target_date": - return sortBy(array, "target_date"); + return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"]); //preferring sorting based on empty values to always keep the empty values below case "-target_date": - return reverse(sortBy(array, "target_date")); + return orderBy( + array, + [this.getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ); // custom case "priority": { const sortArray = ISSUE_PRIORITIES.map((i) => i.key); - return reverse(sortBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority))); + return orderBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority), ["desc"]); } case "-priority": { const sortArray = ISSUE_PRIORITIES.map((i) => i.key); - return sortBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority)); + return orderBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority)); } // number case "attachment_count": - return sortBy(array, "attachment_count"); + return orderBy(array, "attachment_count"); case "-attachment_count": - return reverse(sortBy(array, "attachment_count")); + return orderBy(array, "attachment_count", ["desc"]); case "estimate_point": - return sortBy(array, "estimate_point"); + return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"]); //preferring sorting based on empty values to always keep the empty values below case "-estimate_point": - return reverse(sortBy(array, "estimate_point")); + return orderBy( + array, + [this.getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ); case "link_count": - return sortBy(array, "link_count"); + return orderBy(array, "link_count"); case "-link_count": - return reverse(sortBy(array, "link_count")); + return orderBy(array, "link_count", ["desc"]); case "sub_issues_count": - return sortBy(array, "sub_issues_count"); + return orderBy(array, "sub_issues_count"); case "-sub_issues_count": - return reverse(sortBy(array, "sub_issues_count")); + return orderBy(array, "sub_issues_count", ["desc"]); // Array case "labels__name": - return reverse(sortBy(array, "labels")); + return orderBy(array, [ + this.getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("label_ids", issue["label_ids"], "asc"), + ]); case "-labels__name": - return sortBy(array, "labels"); + return orderBy( + array, + [ + this.getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("label_ids", issue["label_ids"], "desc"), + ], + ["asc", "desc"] + ); case "assignees__first_name": - return reverse(sortBy(array, "assignees")); + return orderBy(array, [ + this.getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("assignee_ids", issue["assignee_ids"], "asc"), + ]); case "-assignees__first_name": - return sortBy(array, "assignees"); + return orderBy( + array, + [ + this.getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("assignee_ids", issue["assignee_ids"], "desc"), + ], + ["asc", "desc"] + ); default: return array; diff --git a/web/store/issue/root.store.ts b/web/store/issue/root.store.ts index b2425757c..ee2e6d84d 100644 --- a/web/store/issue/root.store.ts +++ b/web/store/issue/root.store.ts @@ -4,7 +4,7 @@ import isEmpty from "lodash/isEmpty"; import { RootStore } from "../root.store"; import { IStateStore, StateStore } from "../state.store"; // issues data store -import { IState } from "@plane/types"; +import { IIssueLabel, IProject, IState, IUserLite } from "@plane/types"; import { IIssueStore, IssueStore } from "./issue.store"; import { IIssueDetail, IssueDetail } from "./issue-details/root.store"; import { IWorkspaceIssuesFilter, WorkspaceIssuesFilter, IWorkspaceIssues, WorkspaceIssues } from "./workspace"; @@ -22,6 +22,7 @@ import { IArchivedIssuesFilter, ArchivedIssuesFilter, IArchivedIssues, ArchivedI import { IDraftIssuesFilter, DraftIssuesFilter, IDraftIssues, DraftIssues } from "./draft"; import { IIssueKanBanViewStore, IssueKanBanViewStore } from "./issue_kanban_view.store"; import { ICalendarStore, CalendarStore } from "./issue_calendar_view.store"; +import { IWorkspaceMembership } from "store/member/workspace-member.store"; export interface IIssueRootStore { currentUserId: string | undefined; @@ -32,11 +33,12 @@ export interface IIssueRootStore { viewId: string | undefined; globalViewId: string | undefined; // all issues view id userId: string | undefined; // user profile detail Id - states: string[] | undefined; + stateMap: Record | undefined; stateDetails: IState[] | undefined; - labels: string[] | undefined; - members: string[] | undefined; - projects: string[] | undefined; + labelMap: Record | undefined; + workSpaceMemberRolesMap: Record | undefined; + memberMap: Record | undefined; + projectMap: Record | undefined; rootStore: RootStore; @@ -83,11 +85,12 @@ export class IssueRootStore implements IIssueRootStore { viewId: string | undefined = undefined; globalViewId: string | undefined = undefined; userId: string | undefined = undefined; - states: string[] | undefined = undefined; + stateMap: Record | undefined = undefined; stateDetails: IState[] | undefined = undefined; - labels: string[] | undefined = undefined; - members: string[] | undefined = undefined; - projects: string[] | undefined = undefined; + labelMap: Record | undefined = undefined; + workSpaceMemberRolesMap: Record | undefined = undefined; + memberMap: Record | undefined = undefined; + projectMap: Record | undefined = undefined; rootStore: RootStore; @@ -133,11 +136,12 @@ export class IssueRootStore implements IIssueRootStore { viewId: observable.ref, userId: observable.ref, globalViewId: observable.ref, - states: observable, + stateMap: observable, stateDetails: observable, - labels: observable, - members: observable, - projects: observable, + labelMap: observable, + memberMap: observable, + workSpaceMemberRolesMap: observable, + projectMap: observable, }); this.rootStore = rootStore; @@ -151,13 +155,14 @@ export class IssueRootStore implements IIssueRootStore { if (rootStore.app.router.viewId) this.viewId = rootStore.app.router.viewId; if (rootStore.app.router.globalViewId) this.globalViewId = rootStore.app.router.globalViewId; if (rootStore.app.router.userId) this.userId = rootStore.app.router.userId; - if (!isEmpty(rootStore?.state?.stateMap)) this.states = Object.keys(rootStore?.state?.stateMap); + if (!isEmpty(rootStore?.state?.stateMap)) this.stateMap = rootStore?.state?.stateMap; if (!isEmpty(rootStore?.state?.projectStates)) this.stateDetails = rootStore?.state?.projectStates; - if (!isEmpty(rootStore?.label?.labelMap)) this.labels = Object.keys(rootStore?.label?.labelMap); + if (!isEmpty(rootStore?.label?.labelMap)) this.labelMap = rootStore?.label?.labelMap; if (!isEmpty(rootStore?.memberRoot?.workspace?.workspaceMemberMap)) - this.members = Object.keys(rootStore?.memberRoot?.workspace?.workspaceMemberMap); + this.workSpaceMemberRolesMap = rootStore?.memberRoot?.workspace?.memberMap || undefined; + if (!isEmpty(rootStore?.memberRoot?.memberMap)) this.memberMap = rootStore?.memberRoot?.memberMap || undefined; if (!isEmpty(rootStore?.projectRoot?.project?.projectMap)) - this.projects = Object.keys(rootStore?.projectRoot?.project?.projectMap); + this.projectMap = rootStore?.projectRoot?.project?.projectMap; }); this.issues = new IssueStore(); diff --git a/web/store/member/workspace-member.store.ts b/web/store/member/workspace-member.store.ts index ff65d0eb9..1dae25bd4 100644 --- a/web/store/member/workspace-member.store.ts +++ b/web/store/member/workspace-member.store.ts @@ -26,6 +26,7 @@ export interface IWorkspaceMemberStore { // computed workspaceMemberIds: string[] | null; workspaceMemberInvitationIds: string[] | null; + memberMap: Record | null; // computed actions getSearchedWorkspaceMemberIds: (searchQuery: string) => string[] | null; getSearchedWorkspaceInvitationIds: (searchQuery: string) => string[] | null; @@ -68,6 +69,7 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore { // computed workspaceMemberIds: computed, workspaceMemberInvitationIds: computed, + memberMap: computed, // actions fetchWorkspaceMembers: action, updateMember: action, @@ -100,6 +102,12 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore { return memberIds; } + get memberMap() { + const workspaceSlug = this.routerStore.workspaceSlug; + if (!workspaceSlug) return null; + return this.workspaceMemberMap?.[workspaceSlug] ?? {}; + } + get workspaceMemberInvitationIds() { const workspaceSlug = this.routerStore.workspaceSlug; if (!workspaceSlug) return null; diff --git a/web/store/user/index.ts b/web/store/user/index.ts index b07764a05..15f9e5772 100644 --- a/web/store/user/index.ts +++ b/web/store/user/index.ts @@ -250,6 +250,7 @@ export class UserRootStore implements IUserRootStore { this.isUserLoggedIn = false; }); this.membership = new UserMembershipStore(this.rootStore); + this.rootStore.eventTracker.resetSession(); this.rootStore.resetOnSignout(); }); @@ -264,6 +265,7 @@ export class UserRootStore implements IUserRootStore { this.isUserLoggedIn = false; }); this.membership = new UserMembershipStore(this.rootStore); + this.rootStore.eventTracker.resetSession(); this.rootStore.resetOnSignout(); }); }