feat(feedback): gate onboarding survey on first processing, add prompt lifecycle events (#615)

Defers the onboarding usage survey to the instance's first successful processing (the worker writes a one-time onboarding.firstProcessedAt marker and the overlay gates on it), so it reaches engaged users instead of first-landing visitors.

Replaces the two questions telemetry already answers (modality preference from tool_used, install method from instance_started) with what it can't infer: prior tool, self-host motivation, and discovery source.

Adds feedback_prompt_shown and feedback_prompt_dismissed on all five feedback surfaces (usage survey, per-job prompt, admin install card, global nav dialog, search-miss) so skip and completion rates are measurable, not just submissions. New survey strings translated into all 20 non-English locales.
This commit is contained in:
SnapOtter
2026-07-21 16:31:30 +00:00
committed by GitHub
parent b20bca3c3c
commit 129e42b95c
41 changed files with 950 additions and 209 deletions
+58 -61
View File
@@ -6,13 +6,15 @@ import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
const submitFeedback = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, accepted: true }));
const trackFeedbackPromptShown = vi.hoisted(() => vi.fn());
const trackFeedbackPromptDismissed = vi.hoisted(() => vi.fn());
const apiGet = vi.hoisted(() => vi.fn());
const apiPut = vi.hoisted(() => vi.fn().mockResolvedValue({}));
const useAuth = vi.hoisted(() => vi.fn());
vi.mock("@/lib/feedback", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, submitFeedback };
return { ...actual, submitFeedback, trackFeedbackPromptShown, trackFeedbackPromptDismissed };
});
vi.mock("@/lib/api", async (importOriginal) => {
@@ -30,9 +32,15 @@ vi.mock("@/stores/analytics-store", () => ({
import { UsageSurveyOverlay } from "@/components/onboarding/usage-survey-overlay";
// The worker writes this marker on the instance's first successful processing.
// The survey is only eligible once it exists; without it the overlay stays hidden.
const PROCESSED = { "onboarding.firstProcessedAt": "2026-01-01T00:00:00Z" };
afterEach(() => {
cleanup();
submitFeedback.mockClear();
trackFeedbackPromptShown.mockClear();
trackFeedbackPromptDismissed.mockClear();
apiGet.mockClear();
apiPut.mockClear();
useAuth.mockReset();
@@ -56,10 +64,21 @@ describe("UsageSurveyOverlay", () => {
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
});
it("stays hidden until the instance's first processing has completed", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
renderOverlay();
await waitFor(() => expect(apiGet).toHaveBeenCalled());
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
expect(trackFeedbackPromptShown).not.toHaveBeenCalled();
});
it("renders nothing once already answered or dismissed", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({
settings: { "onboarding.usageSurvey.dismissedAt": "2026-01-01T00:00:00Z" },
settings: { ...PROCESSED, "onboarding.usageSurvey.dismissedAt": "2026-01-01T00:00:00Z" },
});
renderOverlay();
@@ -68,28 +87,28 @@ describe("UsageSurveyOverlay", () => {
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
});
it("shows both questions for an admin instance that hasn't answered", async () => {
it("shows the telemetry-blind questions after processing and emits a shown event", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
expect(await screen.findByText("How are you using SnapOtter?")).toBeDefined();
expect(screen.getByText("What matters most to you?")).toBeDefined();
expect(screen.getByText("What were you using before?")).toBeDefined();
expect(screen.getByText("Why self-host it?")).toBeDefined();
expect(screen.getByRole("radio", { name: /Just me/ })).toBeDefined();
expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled();
await waitFor(() => expect(trackFeedbackPromptShown).toHaveBeenCalledWith("onboarding"));
});
it("submits the selected answers and records the settings key", async () => {
it("submits only the usage type when nothing else is picked", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("radio", { name: /Small team/ }));
fireEvent.click(screen.getByRole("button", { name: /Images/ }));
fireEvent.click(screen.getByRole("button", { name: /PDF\/docs/ }));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
@@ -98,7 +117,6 @@ describe("UsageSurveyOverlay", () => {
surveyId: "onboarding-usage-v1",
promptVariant: "onboarding-overlay-v1",
usageType: "team_internal",
importantAreas: ["images", "pdf_docs"],
});
});
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
@@ -106,17 +124,18 @@ describe("UsageSurveyOverlay", () => {
});
});
it("includes install method and friction area when the admin selects them", async () => {
it("includes prior tool, motivation, and discovery source when the admin selects them", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("radio", { name: /Just me/ }));
fireEvent.click(screen.getByRole("radio", { name: "Built from source" }));
fireEvent.change(screen.getByLabelText("Hardest setup area"), {
target: { value: "docker" },
fireEvent.click(screen.getByRole("radio", { name: /Command line/ }));
fireEvent.click(screen.getByRole("radio", { name: /Privacy and data control/ }));
fireEvent.change(screen.getByLabelText(/How did you hear about us/), {
target: { value: "github" },
});
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
@@ -126,36 +145,34 @@ describe("UsageSurveyOverlay", () => {
surveyId: "onboarding-usage-v1",
promptVariant: "onboarding-overlay-v1",
usageType: "personal",
importantAreas: [],
installMethod: "source",
frictionArea: "docker",
priorTool: "command_line",
selfHostMotivation: "privacy_control",
discoverySource: "github",
});
});
});
it("does not resubmit feedback if only the settings write failed on the first attempt", async () => {
it("dismissing writes the dismiss key, emits a dismissed event, and does not submit", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
apiPut.mockRejectedValueOnce(new Error("network error")).mockResolvedValueOnce({});
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("radio", { name: /Just me/ }));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
fireEvent.click(screen.getByRole("button", { name: "Don't ask again" }));
await waitFor(() => expect(apiPut).toHaveBeenCalledTimes(1));
expect(submitFeedback).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(apiPut).toHaveBeenCalledTimes(2));
expect(submitFeedback).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
"onboarding.usageSurvey.dismissedAt": expect.any(String),
});
});
expect(trackFeedbackPromptDismissed).toHaveBeenCalledWith("onboarding", "dont_ask_again");
expect(submitFeedback).not.toHaveBeenCalled();
});
it("resubmits feedback if the answer changes after a failed settings write", async () => {
it("does not resubmit feedback if only the settings write failed on the first attempt", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
apiGet.mockResolvedValue({ settings: PROCESSED });
apiPut.mockRejectedValueOnce(new Error("network error")).mockResolvedValueOnce({});
renderOverlay();
@@ -163,22 +180,19 @@ describe("UsageSurveyOverlay", () => {
fireEvent.click(screen.getByRole("radio", { name: /Just me/ }));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(apiPut).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole("radio", { name: /Small team/ }));
await waitFor(() => expect(apiPut).toHaveBeenCalledTimes(1));
expect(submitFeedback).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(apiPut).toHaveBeenCalledTimes(2));
expect(submitFeedback).toHaveBeenCalledTimes(2);
expect(submitFeedback).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ usageType: "team_internal" }),
);
expect(submitFeedback).toHaveBeenCalledTimes(1);
});
it("stays visible and re-enables Continue if the feedback submission itself fails", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
apiGet.mockResolvedValue({ settings: PROCESSED });
submitFeedback.mockRejectedValueOnce(new Error("network error"));
renderOverlay();
@@ -196,26 +210,9 @@ describe("UsageSurveyOverlay", () => {
expect(apiPut).not.toHaveBeenCalled();
});
it("dismissing writes the dismiss key without submitting feedback", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("button", { name: "Don't ask again" }));
await waitFor(() => {
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
"onboarding.usageSurvey.dismissedAt": expect.any(String),
});
});
expect(submitFeedback).not.toHaveBeenCalled();
});
it("ignores a second dismiss click while the first write is in flight", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
apiGet.mockResolvedValue({ settings: PROCESSED });
let resolveApiPut: (() => void) | undefined;
apiPut.mockImplementationOnce(
() =>
@@ -244,7 +241,7 @@ describe("UsageSurveyOverlay", () => {
it("renders nothing when the admin must still change their password", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: true });
apiGet.mockResolvedValue({ settings: {} });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
@@ -255,7 +252,7 @@ describe("UsageSurveyOverlay", () => {
it("renders nothing on the change-password route even if mustChangePassword is stale-false", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay("/change-password");
@@ -266,7 +263,7 @@ describe("UsageSurveyOverlay", () => {
it("renders nothing on the privacy policy route", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay("/privacy");