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
1 change: 1 addition & 0 deletions apps/web/src/web-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ container.bind(ANALYTICS_TRACKER).toConstantValue({
identifyUser: () => {},
setUserGroups: () => {},
resetUser: () => {},
captureSurveyResponse: () => {},
});
container.bind(IMPERATIVE_QUERY_CLIENT).toConstantValue(queryClient);

Expand Down
81 changes: 81 additions & 0 deletions packages/ui/src/features/canvas/components/FeedbackModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Theme } from "@radix-ui/themes";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";

const { captureSurveyResponse } = vi.hoisted(() => ({
captureSurveyResponse: vi.fn(),
}));

vi.mock("@posthog/ui/shell/analytics", () => ({ captureSurveyResponse }));

import { FeedbackModal, type FeedbackModalMode } from "./FeedbackModal";

function renderModal(mode: FeedbackModalMode | null, onFinished = vi.fn()) {
render(
<Theme>
<FeedbackModal mode={mode} onFinished={onFinished} />
</Theme>,
);
return onFinished;
}

describe("FeedbackModal", () => {
beforeEach(() => {
captureSurveyResponse.mockReset();
});

it.each([
{ mode: "leaving" as const, expected: "Skip", missing: "Cancel" },
{ mode: "feedback" as const, expected: "Cancel", missing: "Skip" },
])(
"shows the $expected secondary button in $mode mode",
({ mode, expected, missing }) => {
renderModal(mode);
expect(
screen.getByRole("button", { name: expected }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: missing }),
).not.toBeInTheDocument();
},
);

it("disables submit until text is entered", async () => {
const user = userEvent.setup();
Comment thread
greptile-apps[bot] marked this conversation as resolved.
renderModal("feedback");
const submit = screen.getByRole("button", { name: "Send feedback" });
// The quill Button signals disabled state via aria-disabled, not the native attr.
expect(submit).toHaveAttribute("aria-disabled", "true");

await user.type(screen.getByPlaceholderText("Share your feedback"), "hi");
expect(submit).not.toHaveAttribute("aria-disabled", "true");
});

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

await user.type(
screen.getByPlaceholderText("Share your feedback"),
" great work ",
);
await user.click(screen.getByRole("button", { name: "Send feedback" }));

expect(captureSurveyResponse).toHaveBeenCalledTimes(1);
expect(captureSurveyResponse).toHaveBeenCalledWith(
expect.objectContaining({ response: "great work" }),
);
expect(onFinished).toHaveBeenCalledTimes(1);
});

it("finishes without capturing when skipped", async () => {
const user = userEvent.setup();
const onFinished = renderModal("leaving");

await user.click(screen.getByRole("button", { name: "Skip" }));

expect(captureSurveyResponse).not.toHaveBeenCalled();
expect(onFinished).toHaveBeenCalledTimes(1);
});
});
110 changes: 110 additions & 0 deletions packages/ui/src/features/canvas/components/FeedbackModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {
Button,
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Textarea,
} from "@posthog/quill";
import {
FEEDBACK_SURVEY_ID,
FEEDBACK_SURVEY_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 interface FeedbackModalProps {
/** `null` closes the modal. `"leaving"` shows a Skip button, `"feedback"` a Cancel 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".
*/
export function FeedbackModal({ mode, onFinished }: FeedbackModalProps) {
const open = mode !== null;

return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
// Esc / outside-click dismiss behaves like the secondary button.
if (!isOpen) onFinished();
}}
>
<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.
</DialogDescription>
</DialogHeader>
{/* Mounted only while open so the textarea resets on each open without
syncing state to the `mode` prop in an effect. */}
{mode !== null && (
<FeedbackModalForm mode={mode} onFinished={onFinished} />
)}
</DialogContent>
</Dialog>
);
}

function FeedbackModalForm({
mode,
onFinished,
}: {
mode: FeedbackModalMode;
onFinished: () => void;
}) {
const [value, setValue] = useState("");

const handleSubmit = () => {
const response = value.trim();
if (!response) return;
captureSurveyResponse({
surveyId: FEEDBACK_SURVEY_ID,
questionId: FEEDBACK_SURVEY_QUESTION_ID,
response,
});
onFinished();
};

return (
<>
<DialogBody>
<Textarea
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="Share your feedback"
rows={4}
maxLength={4000}
autoFocus
/>
</DialogBody>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onFinished}>
{mode === "leaving" ? "Skip" : "Cancel"}
</Button>
<Button
variant="primary"
size="sm"
disabled={value.trim().length === 0}
onClick={handleSubmit}
>
Send feedback
</Button>
</DialogFooter>
</>
);
}
8 changes: 8 additions & 0 deletions packages/ui/src/features/canvas/feedbackSurvey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// PostHog survey backing the Channels "Leave feedback" modal. Created via the
// MCP in project 2 ("PostHog App + Website") and launched so it collects
// responses. Responses are sent client-side as a `survey sent` event and only
// 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";
export const FEEDBACK_SURVEY_QUESTION_ID =
"d1b5bf40-c255-434f-8799-d4ca13873d74";
33 changes: 30 additions & 3 deletions packages/ui/src/router/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
} from "@posthog/shared";
import { UsageLimitModal } from "@posthog/ui/features/billing/UsageLimitModal";
import { ChannelsSidebar } from "@posthog/ui/features/canvas/components/ChannelsSidebar";
import {
FeedbackModal,
type FeedbackModalMode,
} from "@posthog/ui/features/canvas/components/FeedbackModal";
import { CommandMenu } from "@posthog/ui/features/command/CommandMenu";
import { KeyboardShortcutsSheet } from "@posthog/ui/features/command/KeyboardShortcutsSheet";
import { useNewTaskDeepLink } from "@posthog/ui/features/deep-links/useNewTaskDeepLink";
Expand Down Expand Up @@ -90,6 +94,18 @@ export const Route = createRootRoute({
function RootLayout() {
const view = useAppView();
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).
const [feedbackMode, setFeedbackMode] = useState<FeedbackModalMode | null>(
null,
);
const handleFeedbackFinished = () => {
const wasLeaving = feedbackMode === "leaving";
setFeedbackMode(null);
if (wasLeaving) navigate({ to: "/code" });
};
const {
isOpen: commandMenuOpen,
setOpen: setCommandMenuOpen,
Expand Down Expand Up @@ -214,15 +230,22 @@ function RootLayout() {
<Box className="h-[14px] w-[26px] overflow-hidden [&>svg]:h-[14px] [&>svg]:w-auto">
<LogosLandscape code={false} />
</Box>
<div className="no-drag">
<Flex align="center" gap="2" className="no-drag">
<Button
variant="outline"
size="sm"
onClick={() => navigate({ to: "/code" })}
onClick={() => setFeedbackMode("leaving")}
>
Go back to Code
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setFeedbackMode("feedback")}
>
Leave feedback
</Button>
</Flex>
</Flex>
<Flex flexGrow="1" overflow="hidden">
<ChannelsSidebar />
Expand All @@ -245,6 +268,10 @@ function RootLayout() {
/>
{billingEnabled && <UsageLimitModal />}
<RemoteBranchCheckoutDialog />
<FeedbackModal
mode={feedbackMode}
onFinished={handleFeedbackFinished}
/>
{import.meta.env.DEV && (
<Suspense fallback={null}>
<TanStackDevtools />
Expand Down
15 changes: 15 additions & 0 deletions packages/ui/src/shell/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export interface AnalyticsTracker {
identifyUser(userId: string, properties?: UserIdentifyProperties): void;
setUserGroups(user: AnalyticsUserGroups): void;
resetUser(): void;
captureSurveyResponse(params: {
surveyId: string;
questionId: string;
response: string;
}): void;
}

export const ANALYTICS_TRACKER = Symbol.for("posthog.ui.AnalyticsTracker");
Expand Down Expand Up @@ -74,3 +79,13 @@ export function setUserGroups(user: AnalyticsUserGroups): void {
export function resetUser(): void {
resolveService<AnalyticsTracker>(ANALYTICS_TRACKER).resetUser();
}

export function captureSurveyResponse(params: {
surveyId: string;
questionId: string;
response: string;
}): void {
resolveService<AnalyticsTracker>(ANALYTICS_TRACKER).captureSurveyResponse(
params,
);
}
29 changes: 29 additions & 0 deletions packages/ui/src/shell/posthogAnalyticsImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,34 @@ export function track<K extends keyof EventPropertyMap>(
posthog.capture(eventName, args[0]);
}

/**
* 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.
*/
export function captureSurveyResponse({
surveyId,
questionId,
response,
}: {
surveyId: string;
questionId: string;
response: string;
}) {
if (!isInitialized) {
return;
}

posthog.capture("survey sent", {
$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,
});
}

/**
* Build tool metadata for analytics on permission requests
*/
Expand Down Expand Up @@ -318,6 +346,7 @@ export const posthogAnalyticsTracker: AnalyticsTracker = {
identifyUser,
setUserGroups,
resetUser,
captureSurveyResponse,
};

/**
Expand Down
Loading