Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,7 @@ export const ANALYTICS_EVENTS = {
COMMAND_MENU_ACTION: "Command menu action",
COMMAND_CENTER_VIEWED: "Command center viewed",
SKILL_BUTTON_TRIGGERED: "Skill button triggered",
POSTHOG_WEB_OPENED: "PostHog web opened",

// Permission events
PERMISSION_RESPONDED: "Permission responded",
Expand Down Expand Up @@ -978,6 +979,7 @@ export type EventPropertyMap = {
[ANALYTICS_EVENTS.COMMAND_MENU_ACTION]: CommandMenuActionProperties;
[ANALYTICS_EVENTS.COMMAND_CENTER_VIEWED]: never;
[ANALYTICS_EVENTS.SKILL_BUTTON_TRIGGERED]: SkillButtonTriggeredProperties;
[ANALYTICS_EVENTS.POSTHOG_WEB_OPENED]: never;

// Permission events
[ANALYTICS_EVENTS.PERMISSION_RESPONDED]: PermissionRespondedProperties;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe("FeedbackModal", () => {

it.each([
{ mode: "leaving" as const, expected: "Skip", missing: "Cancel" },
{ mode: "posthog-web" as const, expected: "Skip", missing: "Cancel" },
{ mode: "feedback" as const, expected: "Cancel", missing: "Skip" },
])(
"shows the $expected secondary button in $mode mode",
Expand All @@ -52,9 +53,9 @@ describe("FeedbackModal", () => {
expect(submit).not.toHaveAttribute("aria-disabled", "true");
});

it("captures the trimmed response and finishes on submit", async () => {
it("captures the trimmed response with its source and finishes on submit", async () => {
const user = userEvent.setup();
const onFinished = renderModal("leaving");
const onFinished = renderModal("posthog-web");

await user.type(
screen.getByPlaceholderText("Share your feedback"),
Expand All @@ -64,7 +65,12 @@ describe("FeedbackModal", () => {

expect(captureSurveyResponse).toHaveBeenCalledTimes(1);
expect(captureSurveyResponse).toHaveBeenCalledWith(
expect.objectContaining({ response: "great work" }),
expect.objectContaining({
responses: [
expect.objectContaining({ response: "great work" }),
expect.objectContaining({ response: "Visiting PostHog web" }),
],
}),
);
expect(onFinished).toHaveBeenCalledTimes(1);
});
Expand Down
52 changes: 40 additions & 12 deletions packages/ui/src/features/canvas/components/FeedbackModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,48 @@ import {
Textarea,
} from "@posthog/quill";
import {
FEEDBACK_SOURCE_BY_MODE,
FEEDBACK_SURVEY_ID,
FEEDBACK_SURVEY_QUESTION_ID,
FEEDBACK_SURVEY_SOURCE_QUESTION_ID,
} from "@posthog/ui/features/canvas/feedbackSurvey";
import { captureSurveyResponse } from "@posthog/ui/shell/analytics";
import { useState } from "react";

export type FeedbackModalMode = "feedback" | "leaving";
export type FeedbackModalMode = "feedback" | "leaving" | "posthog-web";

/** Title + prompt shown for each way the modal can be opened. */
const MODAL_COPY: Record<FeedbackModalMode, { title: string; prompt: string }> =
{
feedback: {
title: "Leave feedback",
prompt:
"How's the Channels experience? Tell us what's working and what you'd change.",
},
leaving: {
title: "Before you go back to Code",
prompt:
"How's the Channels experience? Tell us what's working and what you'd change.",
},
"posthog-web": {
title: "Before you head to PostHog web",
prompt: "Why are you going back to PostHog web?",
},
};

export interface FeedbackModalProps {
/** `null` closes the modal. `"leaving"` shows a Skip button, `"feedback"` a Cancel button. */
/** `null` closes the modal. `"feedback"` shows a Cancel button; the navigation-intercept modes (`"leaving"`, `"posthog-web"`) show a Skip button. */
mode: FeedbackModalMode | null;
/** Called after the response is submitted, and when the modal is skipped/cancelled/dismissed. */
onFinished: () => void;
}

/**
* Feedback modal for the Channels space. Submitting records the text as a
* PostHog survey response (see {@link FEEDBACK_SURVEY_ID}). The secondary button
* reads "Skip" when opened by "Go back to Code" (`mode === "leaving"`) and
* "Cancel" when opened by "Leave feedback".
* PostHog survey response (see {@link FEEDBACK_SURVEY_ID}) along with where the
* modal was opened from. The secondary button reads "Skip" when the modal
* intercepts a navigation (`"leaving"` / `"posthog-web"`) and "Cancel" when
* opened directly by "Leave feedback".
*/
export function FeedbackModal({ mode, onFinished }: FeedbackModalProps) {
const open = mode !== null;
Expand All @@ -44,10 +66,11 @@ export function FeedbackModal({ mode, onFinished }: FeedbackModalProps) {
>
<DialogContent showCloseButton={false} className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Leave feedback</DialogTitle>
<DialogDescription>
How's the Channels experience? Tell us what's working and what you'd
change.
<DialogTitle>{mode ? MODAL_COPY[mode].title : ""}</DialogTitle>
{/* The prompt is the question we want answered, so render it at full
contrast rather than the muted default. */}
<DialogDescription className="text-base text-gray-12">
{mode ? MODAL_COPY[mode].prompt : ""}
</DialogDescription>
</DialogHeader>
{/* Mounted only while open so the textarea resets on each open without
Expand All @@ -74,8 +97,13 @@ function FeedbackModalForm({
if (!response) return;
captureSurveyResponse({
surveyId: FEEDBACK_SURVEY_ID,
questionId: FEEDBACK_SURVEY_QUESTION_ID,
response,
responses: [
{ questionId: FEEDBACK_SURVEY_QUESTION_ID, response },
{
questionId: FEEDBACK_SURVEY_SOURCE_QUESTION_ID,
response: FEEDBACK_SOURCE_BY_MODE[mode],
},
],
});
onFinished();
};
Expand All @@ -94,7 +122,7 @@ function FeedbackModalForm({
</DialogBody>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onFinished}>
{mode === "leaving" ? "Skip" : "Cancel"}
{mode === "feedback" ? "Cancel" : "Skip"}
</Button>
<Button
variant="primary"
Expand Down
18 changes: 17 additions & 1 deletion packages/ui/src/features/canvas/feedbackSurvey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,21 @@
// attach to this survey if the app reports to the same project.
// https://us.posthog.com/project/2/surveys/019ee235-2e3b-0000-64b3-5f2efa487452
export const FEEDBACK_SURVEY_ID = "019ee235-2e3b-0000-64b3-5f2efa487452";

// Open-text question: "How's the Channels experience? What would you change?".
export const FEEDBACK_SURVEY_QUESTION_ID =
"d1b5bf40-c255-434f-8799-d4ca13873d74";
"68648b23-caaf-4080-ae5f-051513d3097f";

// Single-choice question recording where the feedback was submitted from. The
// submitted response string must match one of the survey's choices exactly —
// see FEEDBACK_SOURCE_BY_MODE.
export const FEEDBACK_SURVEY_SOURCE_QUESTION_ID =
"e4560a6b-3eab-4c61-a731-8d0c10dd1b7d";

// Maps the modal's open reason to the source question's choice label. Values
// MUST stay in sync with the survey's single_choice options.
export const FEEDBACK_SOURCE_BY_MODE = {
feedback: "Generic (Leave feedback button)",
leaving: "Going back to Code",
"posthog-web": "Visiting PostHog web",
} as const;
47 changes: 43 additions & 4 deletions packages/ui/src/router/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ArrowSquareOut } from "@phosphor-icons/react";
import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react";
import { Button } from "@posthog/quill";
import {
Expand All @@ -6,6 +7,8 @@ import {
PROJECT_BLUEBIRD_FLAG,
SYNC_CLOUD_TASKS_FLAG,
} from "@posthog/shared";
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import { useAuthStateValue } from "@posthog/ui/features/auth/store";
import { UsageLimitModal } from "@posthog/ui/features/billing/UsageLimitModal";
import { ChannelsSidebar } from "@posthog/ui/features/canvas/components/ChannelsSidebar";
import {
Expand All @@ -31,6 +34,7 @@ import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace";
import LogosLandscape from "@posthog/ui/primitives/Logo";
import { useAppView } from "@posthog/ui/router/useAppView";
import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask";
import { track } from "@posthog/ui/shell/analytics";
import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore";
import { GlobalEventHandlers } from "@posthog/ui/shell/GlobalEventHandlers";
import { HeaderRow } from "@posthog/ui/shell/HeaderRow";
Expand All @@ -39,6 +43,8 @@ import { logger } from "@posthog/ui/shell/logger";
import { onFeatureFlagsLoaded } from "@posthog/ui/shell/posthogAnalyticsImpl";
import { SpaceSwitcher } from "@posthog/ui/shell/SpaceSwitcher";
import { useShortcutsSheetStore } from "@posthog/ui/shell/shortcutsSheetStore";
import { openUrlInBrowser } from "@posthog/ui/utils/browser";
import { getPostHogUrl } from "@posthog/ui/utils/urls";
import { Box, Flex } from "@radix-ui/themes";
import { useQueryClient } from "@tanstack/react-query";
import {
Expand Down Expand Up @@ -96,15 +102,37 @@ function RootLayout() {
const navigate = useNavigate();

// Feedback modal shown in the Channels title bar. Opened directly by "Leave
// feedback" (mode "feedback") or as an intercept before "Go back to Code"
// (mode "leaving", which routes to /code once submitted or skipped).
// feedback" (mode "feedback"), or as an intercept before navigating away —
// "Go back to Code" (mode "leaving") and "PostHog Web" (mode "posthog-web"),
// each of which routes once the modal is submitted or skipped.
const [feedbackMode, setFeedbackMode] = useState<FeedbackModalMode | null>(
null,
);
const currentProjectId = useAuthStateValue((s) => s.currentProjectId);

// The user's current project on the correct cloud (region comes from
// cloudRegion via getPostHogUrl), falling back to the account root. `null`
// when the region is unknown — the "PostHog Web" button is disabled then, so
// a click can never silently no-op.
const posthogWebUrl = getPostHogUrl(
currentProjectId ? `/project/${currentProjectId}` : "/",
);

// Both "Go back to Code" and "PostHog Web" open the feedback modal first and
// perform their navigation only once it's submitted or skipped.
const handleFeedbackFinished = () => {
const wasLeaving = feedbackMode === "leaving";
const finishedMode = feedbackMode;
setFeedbackMode(null);
if (wasLeaving) navigate({ to: "/code" });
if (finishedMode === "leaving") {
navigate({ to: "/code" });
} else if (finishedMode === "posthog-web" && posthogWebUrl) {
void openUrlInBrowser(posthogWebUrl);
}
};

const handleOpenPostHogWeb = () => {
track(ANALYTICS_EVENTS.POSTHOG_WEB_OPENED);
setFeedbackMode("posthog-web");
};
const {
isOpen: commandMenuOpen,
Expand Down Expand Up @@ -246,6 +274,17 @@ function RootLayout() {
Leave feedback
</Button>
</Flex>
<Flex align="center" className="no-drag ml-auto pr-3">
<Button
variant="outline"
size="sm"
disabled={!posthogWebUrl}
onClick={handleOpenPostHogWeb}
>
<ArrowSquareOut size={14} />
PostHog Web
</Button>
</Flex>
</Flex>
<Flex flexGrow="1" overflow="hidden">
<ChannelsSidebar />
Expand Down
6 changes: 2 additions & 4 deletions packages/ui/src/shell/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ export interface AnalyticsTracker {
resetUser(): void;
captureSurveyResponse(params: {
surveyId: string;
questionId: string;
response: string;
responses: Array<{ questionId: string; response: string }>;
}): void;
}

Expand Down Expand Up @@ -82,8 +81,7 @@ export function resetUser(): void {

export function captureSurveyResponse(params: {
surveyId: string;
questionId: string;
response: string;
responses: Array<{ questionId: string; response: string }>;
}): void {
resolveService<AnalyticsTracker>(ANALYTICS_TRACKER).captureSurveyResponse(
params,
Expand Down
34 changes: 20 additions & 14 deletions packages/ui/src/shell/posthogAnalyticsImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,31 +219,37 @@ export function track<K extends keyof EventPropertyMap>(
}

/**
* Record a survey response via posthog-js's `survey sent` event. The survey
* must already exist (and be launched) in the project the app reports to, or
* the response will not attach to it.
* Record a survey response via posthog-js's `survey sent` event. Pass one entry
* per answered question; they're submitted together as a single response. The
* survey must already exist (and be launched) in the project the app reports to,
* or the response will not attach to it.
*/
export function captureSurveyResponse({
surveyId,
questionId,
response,
responses,
}: {
surveyId: string;
questionId: string;
response: string;
responses: Array<{ questionId: string; response: string }>;
}) {
if (!isInitialized) {
return;
}

posthog.capture("survey sent", {
const properties: Record<string, unknown> = {
$survey_id: surveyId,
$survey_questions: [{ id: questionId }],
// Newer ingestion keys responses by question id; `$survey_response` is the
// legacy single-question key. Send both so the response attaches either way.
[`$survey_response_${questionId}`]: response,
$survey_response: response,
});
$survey_questions: responses.map(({ questionId }) => ({ id: questionId })),
};
// Newer ingestion keys each response by question id.
for (const { questionId, response } of responses) {
properties[`$survey_response_${questionId}`] = response;
}
// `$survey_response` is the legacy single-question key; only set it when there
// is exactly one answer, otherwise it would be ambiguous.
if (responses.length === 1) {
properties.$survey_response = responses[0].response;
}

posthog.capture("survey sent", properties);
}

/**
Expand Down
Loading