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..c8cb744323 --- /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.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(); + 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..0bc10d6f92 --- /dev/null +++ b/packages/ui/src/features/canvas/components/FeedbackModal.tsx @@ -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 ( + { + // 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. + + + {/* Mounted only while open so the textarea resets on each open without + syncing state to the `mode` prop in an effect. */} + {mode !== null && ( + + )} + + + ); +} + +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 ( + <> + +