From 3dc9389829027734db62e56252891f60a9b9636f Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Fri, 19 Jun 2026 17:00:53 -0700 Subject: [PATCH 1/4] feat(canvas): add Leave feedback survey modal to Channels view Adds a "Leave feedback" button next to "Go back to Code" in the project-bluebird Channels title bar. Clicking it opens a modal with a text box that records the input as a PostHog survey response. "Go back to Code" now opens the same modal first as a gentle intercept. The secondary button reads "Skip" when opened that way and "Cancel" when opened via "Leave feedback". After submit or skip in the leaving flow, the user is routed back to /code; in the feedback flow submit/cancel just closes. Survey responses are sent via posthog-js's "survey sent" event through the existing AnalyticsTracker seam (new captureSurveyResponse method), so feature code never imports posthog-js directly. The backing survey is an api-type single-open-question survey created in PostHog (id + question id stored in feedbackSurvey.ts). Generated-By: PostHog Code Task-Id: d326ecd6-fe74-4c86-8c15-6f028888ebc2 --- apps/web/src/web-container.ts | 1 + .../canvas/components/FeedbackModal.test.tsx | 81 ++++++++++++++++ .../canvas/components/FeedbackModal.tsx | 93 +++++++++++++++++++ .../ui/src/features/canvas/feedbackSurvey.ts | 8 ++ packages/ui/src/router/routes/__root.tsx | 30 +++++- packages/ui/src/shell/analytics.ts | 15 +++ packages/ui/src/shell/posthogAnalyticsImpl.ts | 29 ++++++ 7 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/FeedbackModal.test.tsx create mode 100644 packages/ui/src/features/canvas/components/FeedbackModal.tsx create mode 100644 packages/ui/src/features/canvas/feedbackSurvey.ts diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts index f2e4332d08..d871b48534 100644 --- a/apps/web/src/web-container.ts +++ b/apps/web/src/web-container.ts @@ -76,6 +76,7 @@ container.bind(ANALYTICS_TRACKER).toConstantValue({ identifyUser: () => {}, setUserGroups: () => {}, resetUser: () => {}, + captureSurveyResponse: () => {}, }); container.bind(IMPERATIVE_QUERY_CLIENT).toConstantValue(queryClient); diff --git a/packages/ui/src/features/canvas/components/FeedbackModal.test.tsx b/packages/ui/src/features/canvas/components/FeedbackModal.test.tsx new file mode 100644 index 0000000000..cc1fd1953a --- /dev/null +++ b/packages/ui/src/features/canvas/components/FeedbackModal.test.tsx @@ -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( + + + , + ); + return onFinished; +} + +describe("FeedbackModal", () => { + beforeEach(() => { + captureSurveyResponse.mockReset(); + }); + + it("shows a Skip button when opened via Go back to Code", () => { + renderModal("leaving"); + expect(screen.getByRole("button", { name: "Skip" })).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Cancel" }), + ).not.toBeInTheDocument(); + }); + + it("shows a Cancel button when opened via Leave feedback", () => { + renderModal("feedback"); + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Skip" }), + ).not.toBeInTheDocument(); + }); + + it("disables submit until text is entered", async () => { + const user = userEvent.setup(); + 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); + }); +}); diff --git a/packages/ui/src/features/canvas/components/FeedbackModal.tsx b/packages/ui/src/features/canvas/components/FeedbackModal.tsx new file mode 100644 index 0000000000..38016fc142 --- /dev/null +++ b/packages/ui/src/features/canvas/components/FeedbackModal.tsx @@ -0,0 +1,93 @@ +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} 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 { TextArea } from "@radix-ui/themes"; +import { useEffect, 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; + const [value, setValue] = useState(""); + + // Reset the textarea every time the modal opens. + useEffect(() => { + if (open) setValue(""); + }, [open]); + + const handleSubmit = () => { + const response = value.trim(); + if (!response) return; + captureSurveyResponse({ + surveyId: FEEDBACK_SURVEY_ID, + questionId: FEEDBACK_SURVEY_QUESTION_ID, + response, + }); + onFinished(); + }; + + return ( + { + // Esc / outside-click dismiss behaves like the secondary button. + if (!isOpen) onFinished(); + }} + > + + + Leave feedback + + How's the Channels experience? Tell us what's working and what you'd + change. + + +