From c3a8301b0cdc5bea32102bd2b2696253f7c3e04f Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:28:03 +0000 Subject: [PATCH] fix: hide feature opt-in feedback dialog during impersonation (#27802) * fix: hide feature opt-in banner and feedback forms during impersonation Co-Authored-By: sean@cal.com * fix: keep opt-in banner visible during impersonation, only hide feedback forms Co-Authored-By: sean@cal.com * fix: allow feedback form during impersonation in development mode Co-Authored-By: sean@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../hooks/useFeatureOptInBanner.test.ts | 92 +++++++++++++++++ .../hooks/useOptInFeedback.test.ts | 99 +++++++++++++++++++ .../feature-opt-in/hooks/useOptInFeedback.ts | 12 ++- 3 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.test.ts create mode 100644 apps/web/modules/feature-opt-in/hooks/useOptInFeedback.test.ts diff --git a/apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.test.ts b/apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.test.ts new file mode 100644 index 0000000000..a2835f52aa --- /dev/null +++ b/apps/web/modules/feature-opt-in/hooks/useFeatureOptInBanner.test.ts @@ -0,0 +1,92 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useFeatureOptInBanner } from "./useFeatureOptInBanner"; + +const mockUseSession = vi.fn(); +vi.mock("next-auth/react", () => ({ + useSession: () => mockUseSession(), +})); + +vi.mock("@calcom/features/feature-opt-in/config", () => ({ + getOptInFeatureConfig: vi.fn(() => ({ + slug: "bookings-v3", + i18n: { title: "t", name: "n", description: "d" }, + bannerImage: { src: "/img.png", width: 100, height: 100 }, + policy: "permissive", + displayLocations: ["banner", "settings"], + })), + shouldDisplayFeatureAt: vi.fn(() => true), +})); + +vi.mock("../lib/feature-opt-in-storage", () => ({ + getFeatureOptInTimestamp: vi.fn(() => null), + isFeatureDismissed: vi.fn(() => false), + setFeatureDismissed: vi.fn(), + setFeatureOptedIn: vi.fn(), + isFeatureFeedbackShown: vi.fn(() => false), + setFeatureFeedbackShown: vi.fn(), +})); + +const mockMutateAsync = vi.fn(); +const mockInvalidate = vi.fn(); +vi.mock("@calcom/trpc/react", () => ({ + trpc: { + useUtils: vi.fn(() => ({ + viewer: { + featureOptIn: { + checkFeatureOptInEligibility: { invalidate: mockInvalidate }, + listForUser: { invalidate: mockInvalidate }, + }, + }, + })), + viewer: { + featureOptIn: { + checkFeatureOptInEligibility: { + useQuery: vi.fn(() => ({ + data: { + status: "can_opt_in", + canOptIn: true, + blockingReason: null, + userRoleContext: { isOrgAdmin: false, orgId: null, adminTeamIds: [], adminTeamNames: [] }, + }, + isLoading: false, + })), + }, + setUserState: { useMutation: vi.fn(() => ({ mutateAsync: mockMutateAsync })) }, + setTeamState: { useMutation: vi.fn(() => ({ mutateAsync: mockMutateAsync })) }, + setOrganizationState: { useMutation: vi.fn(() => ({ mutateAsync: mockMutateAsync })) }, + setUserAutoOptIn: { useMutation: vi.fn(() => ({ mutateAsync: mockMutateAsync })) }, + setTeamAutoOptIn: { useMutation: vi.fn(() => ({ mutateAsync: mockMutateAsync })) }, + setOrganizationAutoOptIn: { useMutation: vi.fn(() => ({ mutateAsync: mockMutateAsync })) }, + }, + }, + }, +})); + +vi.mock("posthog-js", () => ({ + default: { capture: vi.fn() }, +})); + +describe("useFeatureOptInBanner", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows banner for normal users when eligible", () => { + mockUseSession.mockReturnValue({ data: { user: {} } }); + + const { result } = renderHook(() => useFeatureOptInBanner("bookings-v3")); + + expect(result.current.shouldShow).toBe(true); + }); + + it("still shows banner when user is impersonated", () => { + mockUseSession.mockReturnValue({ + data: { user: { impersonatedBy: { id: 999, email: "admin@cal.com" } } }, + }); + + const { result } = renderHook(() => useFeatureOptInBanner("bookings-v3")); + + expect(result.current.shouldShow).toBe(true); + }); +}); diff --git a/apps/web/modules/feature-opt-in/hooks/useOptInFeedback.test.ts b/apps/web/modules/feature-opt-in/hooks/useOptInFeedback.test.ts new file mode 100644 index 0000000000..222033692b --- /dev/null +++ b/apps/web/modules/feature-opt-in/hooks/useOptInFeedback.test.ts @@ -0,0 +1,99 @@ +import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config"; +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useOptInFeedback } from "./useOptInFeedback"; + +const mockUseSession = vi.fn(); +vi.mock("next-auth/react", () => ({ + useSession: () => mockUseSession(), +})); + +const mockEnv = vi.hoisted(() => ({ isENVDev: false })); +vi.mock("@calcom/lib/env", () => ({ + get isENVDev() { + return mockEnv.isENVDev; + }, +})); + +vi.mock("../lib/feature-opt-in-storage", () => ({ + getFeatureOptInTimestamp: vi.fn(() => Date.now() - 10 * 24 * 60 * 60 * 1000), + isFeatureFeedbackShown: vi.fn(() => false), + setFeatureFeedbackShown: vi.fn(), +})); + +const featureConfig: OptInFeatureConfig = { + slug: "bookings-v3" as OptInFeatureConfig["slug"], + i18n: { title: "t", name: "n", description: "d" }, + bannerImage: { src: "/img.png", width: 100, height: 100 }, + policy: "permissive", + formbricks: { + waitAfterDays: 0, + surveyId: "survey-1", + questions: { ratingQuestionId: "q1", commentQuestionId: "q2" }, + }, +}; + +describe("useOptInFeedback", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockUseSession.mockReturnValue({ data: { user: {} } }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("shows feedback dialog for normal users after delay", () => { + const { result } = renderHook(() => useOptInFeedback("bookings-v3", featureConfig)); + expect(result.current.showFeedbackDialog).toBe(false); + + act(() => { + vi.advanceTimersByTime(6000); + }); + + expect(result.current.showFeedbackDialog).toBe(true); + }); + + it("does not show feedback dialog when user is impersonated in production", () => { + mockEnv.isENVDev = false; + mockUseSession.mockReturnValue({ + data: { user: { impersonatedBy: { id: 999, email: "admin@cal.com" } } }, + }); + + const { result } = renderHook(() => useOptInFeedback("bookings-v3", featureConfig)); + + act(() => { + vi.advanceTimersByTime(6000); + }); + + expect(result.current.showFeedbackDialog).toBe(false); + }); + + it("shows feedback dialog when user is impersonated in development", () => { + mockEnv.isENVDev = true; + mockUseSession.mockReturnValue({ + data: { user: { impersonatedBy: { id: 999, email: "admin@cal.com" } } }, + }); + + const { result } = renderHook(() => useOptInFeedback("bookings-v3", featureConfig)); + + act(() => { + vi.advanceTimersByTime(6000); + }); + + expect(result.current.showFeedbackDialog).toBe(true); + }); + + it("shows feedback dialog when session has no impersonation", () => { + mockUseSession.mockReturnValue({ data: { user: { name: "Test" } } }); + + const { result } = renderHook(() => useOptInFeedback("bookings-v3", featureConfig)); + + act(() => { + vi.advanceTimersByTime(6000); + }); + + expect(result.current.showFeedbackDialog).toBe(true); + }); +}); diff --git a/apps/web/modules/feature-opt-in/hooks/useOptInFeedback.ts b/apps/web/modules/feature-opt-in/hooks/useOptInFeedback.ts index a2a60f6098..96f6c11f0e 100644 --- a/apps/web/modules/feature-opt-in/hooks/useOptInFeedback.ts +++ b/apps/web/modules/feature-opt-in/hooks/useOptInFeedback.ts @@ -1,6 +1,8 @@ "use client"; import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config"; +import { isENVDev } from "@calcom/lib/env"; +import { useSession } from "next-auth/react"; import { useCallback, useEffect, useRef, useState } from "react"; import { getFeatureOptInTimestamp, @@ -31,10 +33,9 @@ export interface OptInFeedbackState { * Hook to manage feedback dialog display after a user opts into a feature. * Shows a custom feedback dialog after a configurable delay (waitAfterDays). */ -function useOptInFeedback( - featureId: string, - featureConfig: OptInFeatureConfig | null -): OptInFeedbackState { +function useOptInFeedback(featureId: string, featureConfig: OptInFeatureConfig | null): OptInFeedbackState { + const { data: session } = useSession(); + const isImpersonating = !!session?.user?.impersonatedBy; const [showFeedbackDialog, setShowFeedbackDialog] = useState(false); const hasTriggeredRef = useRef(false); @@ -44,6 +45,7 @@ function useOptInFeedback( }, [featureId]); useEffect(() => { + if (isImpersonating && !isENVDev) return; if (!featureConfig?.formbricks) return; // Don't trigger if already triggered this session @@ -77,7 +79,7 @@ function useOptInFeedback( // Show after page load delay to let the page finish loading const timer = setTimeout(triggerFeedback, PAGE_LOAD_DELAY_MS); return () => clearTimeout(timer); - }, [featureId, featureConfig]); + }, [featureId, featureConfig, isImpersonating]); const feedbackDialogProps = featureConfig?.formbricks?.surveyId ? {