Skip to content

Commit c3a8301

Browse files
fix: hide feature opt-in feedback dialog during impersonation (calcom#27802)
* fix: hide feature opt-in banner and feedback forms during impersonation Co-Authored-By: sean@cal.com <Sean@brydon.io> * fix: keep opt-in banner visible during impersonation, only hide feedback forms Co-Authored-By: sean@cal.com <Sean@brydon.io> * fix: allow feedback form during impersonation in development mode Co-Authored-By: sean@cal.com <Sean@brydon.io> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent c58e1f4 commit c3a8301

3 files changed

Lines changed: 198 additions & 5 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { useFeatureOptInBanner } from "./useFeatureOptInBanner";
4+
5+
const mockUseSession = vi.fn();
6+
vi.mock("next-auth/react", () => ({
7+
useSession: () => mockUseSession(),
8+
}));
9+
10+
vi.mock("@calcom/features/feature-opt-in/config", () => ({
11+
getOptInFeatureConfig: vi.fn(() => ({
12+
slug: "bookings-v3",
13+
i18n: { title: "t", name: "n", description: "d" },
14+
bannerImage: { src: "/img.png", width: 100, height: 100 },
15+
policy: "permissive",
16+
displayLocations: ["banner", "settings"],
17+
})),
18+
shouldDisplayFeatureAt: vi.fn(() => true),
19+
}));
20+
21+
vi.mock("../lib/feature-opt-in-storage", () => ({
22+
getFeatureOptInTimestamp: vi.fn(() => null),
23+
isFeatureDismissed: vi.fn(() => false),
24+
setFeatureDismissed: vi.fn(),
25+
setFeatureOptedIn: vi.fn(),
26+
isFeatureFeedbackShown: vi.fn(() => false),
27+
setFeatureFeedbackShown: vi.fn(),
28+
}));
29+
30+
const mockMutateAsync = vi.fn();
31+
const mockInvalidate = vi.fn();
32+
vi.mock("@calcom/trpc/react", () => ({
33+
trpc: {
34+
useUtils: vi.fn(() => ({
35+
viewer: {
36+
featureOptIn: {
37+
checkFeatureOptInEligibility: { invalidate: mockInvalidate },
38+
listForUser: { invalidate: mockInvalidate },
39+
},
40+
},
41+
})),
42+
viewer: {
43+
featureOptIn: {
44+
checkFeatureOptInEligibility: {
45+
useQuery: vi.fn(() => ({
46+
data: {
47+
status: "can_opt_in",
48+
canOptIn: true,
49+
blockingReason: null,
50+
userRoleContext: { isOrgAdmin: false, orgId: null, adminTeamIds: [], adminTeamNames: [] },
51+
},
52+
isLoading: false,
53+
})),
54+
},
55+
setUserState: { useMutation: vi.fn(() => ({ mutateAsync: mockMutateAsync })) },
56+
setTeamState: { useMutation: vi.fn(() => ({ mutateAsync: mockMutateAsync })) },
57+
setOrganizationState: { useMutation: vi.fn(() => ({ mutateAsync: mockMutateAsync })) },
58+
setUserAutoOptIn: { useMutation: vi.fn(() => ({ mutateAsync: mockMutateAsync })) },
59+
setTeamAutoOptIn: { useMutation: vi.fn(() => ({ mutateAsync: mockMutateAsync })) },
60+
setOrganizationAutoOptIn: { useMutation: vi.fn(() => ({ mutateAsync: mockMutateAsync })) },
61+
},
62+
},
63+
},
64+
}));
65+
66+
vi.mock("posthog-js", () => ({
67+
default: { capture: vi.fn() },
68+
}));
69+
70+
describe("useFeatureOptInBanner", () => {
71+
beforeEach(() => {
72+
vi.clearAllMocks();
73+
});
74+
75+
it("shows banner for normal users when eligible", () => {
76+
mockUseSession.mockReturnValue({ data: { user: {} } });
77+
78+
const { result } = renderHook(() => useFeatureOptInBanner("bookings-v3"));
79+
80+
expect(result.current.shouldShow).toBe(true);
81+
});
82+
83+
it("still shows banner when user is impersonated", () => {
84+
mockUseSession.mockReturnValue({
85+
data: { user: { impersonatedBy: { id: 999, email: "admin@cal.com" } } },
86+
});
87+
88+
const { result } = renderHook(() => useFeatureOptInBanner("bookings-v3"));
89+
90+
expect(result.current.shouldShow).toBe(true);
91+
});
92+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
2+
import { act, renderHook } from "@testing-library/react";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
import { useOptInFeedback } from "./useOptInFeedback";
5+
6+
const mockUseSession = vi.fn();
7+
vi.mock("next-auth/react", () => ({
8+
useSession: () => mockUseSession(),
9+
}));
10+
11+
const mockEnv = vi.hoisted(() => ({ isENVDev: false }));
12+
vi.mock("@calcom/lib/env", () => ({
13+
get isENVDev() {
14+
return mockEnv.isENVDev;
15+
},
16+
}));
17+
18+
vi.mock("../lib/feature-opt-in-storage", () => ({
19+
getFeatureOptInTimestamp: vi.fn(() => Date.now() - 10 * 24 * 60 * 60 * 1000),
20+
isFeatureFeedbackShown: vi.fn(() => false),
21+
setFeatureFeedbackShown: vi.fn(),
22+
}));
23+
24+
const featureConfig: OptInFeatureConfig = {
25+
slug: "bookings-v3" as OptInFeatureConfig["slug"],
26+
i18n: { title: "t", name: "n", description: "d" },
27+
bannerImage: { src: "/img.png", width: 100, height: 100 },
28+
policy: "permissive",
29+
formbricks: {
30+
waitAfterDays: 0,
31+
surveyId: "survey-1",
32+
questions: { ratingQuestionId: "q1", commentQuestionId: "q2" },
33+
},
34+
};
35+
36+
describe("useOptInFeedback", () => {
37+
beforeEach(() => {
38+
vi.useFakeTimers();
39+
mockUseSession.mockReturnValue({ data: { user: {} } });
40+
});
41+
42+
afterEach(() => {
43+
vi.useRealTimers();
44+
vi.clearAllMocks();
45+
});
46+
47+
it("shows feedback dialog for normal users after delay", () => {
48+
const { result } = renderHook(() => useOptInFeedback("bookings-v3", featureConfig));
49+
expect(result.current.showFeedbackDialog).toBe(false);
50+
51+
act(() => {
52+
vi.advanceTimersByTime(6000);
53+
});
54+
55+
expect(result.current.showFeedbackDialog).toBe(true);
56+
});
57+
58+
it("does not show feedback dialog when user is impersonated in production", () => {
59+
mockEnv.isENVDev = false;
60+
mockUseSession.mockReturnValue({
61+
data: { user: { impersonatedBy: { id: 999, email: "admin@cal.com" } } },
62+
});
63+
64+
const { result } = renderHook(() => useOptInFeedback("bookings-v3", featureConfig));
65+
66+
act(() => {
67+
vi.advanceTimersByTime(6000);
68+
});
69+
70+
expect(result.current.showFeedbackDialog).toBe(false);
71+
});
72+
73+
it("shows feedback dialog when user is impersonated in development", () => {
74+
mockEnv.isENVDev = true;
75+
mockUseSession.mockReturnValue({
76+
data: { user: { impersonatedBy: { id: 999, email: "admin@cal.com" } } },
77+
});
78+
79+
const { result } = renderHook(() => useOptInFeedback("bookings-v3", featureConfig));
80+
81+
act(() => {
82+
vi.advanceTimersByTime(6000);
83+
});
84+
85+
expect(result.current.showFeedbackDialog).toBe(true);
86+
});
87+
88+
it("shows feedback dialog when session has no impersonation", () => {
89+
mockUseSession.mockReturnValue({ data: { user: { name: "Test" } } });
90+
91+
const { result } = renderHook(() => useOptInFeedback("bookings-v3", featureConfig));
92+
93+
act(() => {
94+
vi.advanceTimersByTime(6000);
95+
});
96+
97+
expect(result.current.showFeedbackDialog).toBe(true);
98+
});
99+
});

apps/web/modules/feature-opt-in/hooks/useOptInFeedback.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"use client";
22

33
import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
4+
import { isENVDev } from "@calcom/lib/env";
5+
import { useSession } from "next-auth/react";
46
import { useCallback, useEffect, useRef, useState } from "react";
57
import {
68
getFeatureOptInTimestamp,
@@ -31,10 +33,9 @@ export interface OptInFeedbackState {
3133
* Hook to manage feedback dialog display after a user opts into a feature.
3234
* Shows a custom feedback dialog after a configurable delay (waitAfterDays).
3335
*/
34-
function useOptInFeedback(
35-
featureId: string,
36-
featureConfig: OptInFeatureConfig | null
37-
): OptInFeedbackState {
36+
function useOptInFeedback(featureId: string, featureConfig: OptInFeatureConfig | null): OptInFeedbackState {
37+
const { data: session } = useSession();
38+
const isImpersonating = !!session?.user?.impersonatedBy;
3839
const [showFeedbackDialog, setShowFeedbackDialog] = useState(false);
3940
const hasTriggeredRef = useRef(false);
4041

@@ -44,6 +45,7 @@ function useOptInFeedback(
4445
}, [featureId]);
4546

4647
useEffect(() => {
48+
if (isImpersonating && !isENVDev) return;
4749
if (!featureConfig?.formbricks) return;
4850

4951
// Don't trigger if already triggered this session
@@ -77,7 +79,7 @@ function useOptInFeedback(
7779
// Show after page load delay to let the page finish loading
7880
const timer = setTimeout(triggerFeedback, PAGE_LOAD_DELAY_MS);
7981
return () => clearTimeout(timer);
80-
}, [featureId, featureConfig]);
82+
}, [featureId, featureConfig, isImpersonating]);
8183

8284
const feedbackDialogProps = featureConfig?.formbricks?.surveyId
8385
? {

0 commit comments

Comments
 (0)