mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: critical first-login soft-lock in usage survey overlay (#392)
* fix: prevent UsageSurveyOverlay from soft-locking the first-login password-change flow Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp * fix: prevent double feedback submission when the settings write fails Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp * refactor: consolidate feedback enums into packages/shared as a single source of truth Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp * feat: add ARIA semantics, dismiss-button guard, and shared auth-route list to UsageSurveyOverlay Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp * test: cover the submit-failure retry path and a persona-only minimal payload Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp
This commit is contained in:
@@ -1,4 +1,17 @@
|
|||||||
import { ANALYTICS_BAKED, ANALYTICS_EVENTS, APP_VERSION } from "@snapotter/shared";
|
import {
|
||||||
|
ANALYTICS_BAKED,
|
||||||
|
ANALYTICS_EVENTS,
|
||||||
|
APP_VERSION,
|
||||||
|
type FeedbackErrorCategory,
|
||||||
|
type FeedbackFrictionArea,
|
||||||
|
type FeedbackImportantArea,
|
||||||
|
type FeedbackInstallMethod,
|
||||||
|
type FeedbackSentiment,
|
||||||
|
type FeedbackSource,
|
||||||
|
type FeedbackSurveyId,
|
||||||
|
type FeedbackType,
|
||||||
|
type FeedbackUsageType,
|
||||||
|
} from "@snapotter/shared";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { PostHog } from "posthog-node";
|
import type { PostHog } from "posthog-node";
|
||||||
import { db, schema } from "../db/index.js";
|
import { db, schema } from "../db/index.js";
|
||||||
@@ -7,30 +20,12 @@ import { analyticsEnabled, bakedEnabled } from "./analytics-gate.js";
|
|||||||
|
|
||||||
let posthogClient: PostHog | null = null;
|
let posthogClient: PostHog | null = null;
|
||||||
|
|
||||||
export const FEEDBACK_SOURCE_VALUES = [
|
|
||||||
"global",
|
|
||||||
"tool_result",
|
|
||||||
"failed_job",
|
|
||||||
"admin_installer",
|
|
||||||
"search_miss",
|
|
||||||
"onboarding",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const FEEDBACK_SURVEY_ID_VALUES = [
|
|
||||||
"global-feedback-v1",
|
|
||||||
"tool-result-v1",
|
|
||||||
"failed-job-v1",
|
|
||||||
"admin-install-v1",
|
|
||||||
"search-miss-v1",
|
|
||||||
"onboarding-usage-v1",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export interface FeedbackEventProperties {
|
export interface FeedbackEventProperties {
|
||||||
source: (typeof FEEDBACK_SOURCE_VALUES)[number];
|
source: FeedbackSource;
|
||||||
survey_id?: (typeof FEEDBACK_SURVEY_ID_VALUES)[number];
|
survey_id?: FeedbackSurveyId;
|
||||||
prompt_variant?: string;
|
prompt_variant?: string;
|
||||||
sentiment?: "great" | "okay" | "issue" | "missing" | "bug" | "idea" | "other";
|
sentiment?: FeedbackSentiment;
|
||||||
feedback_type?: "bug" | "feature_request" | "confusing_ux" | "performance" | "other";
|
feedback_type?: FeedbackType;
|
||||||
message?: string;
|
message?: string;
|
||||||
contact_ok: boolean;
|
contact_ok: boolean;
|
||||||
contact_email?: string;
|
contact_email?: string;
|
||||||
@@ -39,28 +34,11 @@ export interface FeedbackEventProperties {
|
|||||||
tool_id?: string;
|
tool_id?: string;
|
||||||
search_query?: string;
|
search_query?: string;
|
||||||
job_status?: "completed" | "failed";
|
job_status?: "completed" | "failed";
|
||||||
install_method?: "docker" | "docker_compose" | "source" | "cloud" | "other";
|
install_method?: FeedbackInstallMethod;
|
||||||
usage_type?: "personal" | "team_internal" | "business_workflow" | "education" | "evaluating";
|
usage_type?: FeedbackUsageType;
|
||||||
important_areas?: string[];
|
important_areas?: FeedbackImportantArea[];
|
||||||
friction_area?:
|
friction_area?: FeedbackFrictionArea;
|
||||||
| "smooth"
|
error_category?: FeedbackErrorCategory;
|
||||||
| "docker"
|
|
||||||
| "environment_variables"
|
|
||||||
| "auth"
|
|
||||||
| "storage"
|
|
||||||
| "workers"
|
|
||||||
| "ai_tools"
|
|
||||||
| "docs"
|
|
||||||
| "performance"
|
|
||||||
| "other";
|
|
||||||
error_category?:
|
|
||||||
| "validation_error"
|
|
||||||
| "upload_error"
|
|
||||||
| "processing_error"
|
|
||||||
| "timeout"
|
|
||||||
| "unsupported_format"
|
|
||||||
| "worker_unavailable"
|
|
||||||
| "unknown";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initAnalytics(): Promise<void> {
|
export async function initAnalytics(): Promise<void> {
|
||||||
|
|||||||
@@ -1,59 +1,20 @@
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
import {
|
||||||
captureFeedback,
|
FEEDBACK_ERROR_CATEGORY_VALUES,
|
||||||
|
FEEDBACK_FRICTION_AREA_VALUES,
|
||||||
|
FEEDBACK_IMPORTANT_AREA_VALUES,
|
||||||
|
FEEDBACK_INSTALL_METHOD_VALUES,
|
||||||
|
FEEDBACK_SENTIMENT_VALUES,
|
||||||
FEEDBACK_SOURCE_VALUES,
|
FEEDBACK_SOURCE_VALUES,
|
||||||
FEEDBACK_SURVEY_ID_VALUES,
|
FEEDBACK_SURVEY_ID_VALUES,
|
||||||
type FeedbackEventProperties,
|
FEEDBACK_TYPE_VALUES,
|
||||||
} from "../lib/analytics.js";
|
FEEDBACK_USAGE_TYPE_VALUES,
|
||||||
|
} from "@snapotter/shared";
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { captureFeedback, type FeedbackEventProperties } from "../lib/analytics.js";
|
||||||
import { analyticsEnabled } from "../lib/analytics-gate.js";
|
import { analyticsEnabled } from "../lib/analytics-gate.js";
|
||||||
import { requireAuth } from "../plugins/auth.js";
|
import { requireAuth } from "../plugins/auth.js";
|
||||||
|
|
||||||
const SENTIMENT_VALUES = ["great", "okay", "issue", "missing", "bug", "idea", "other"] as const;
|
|
||||||
const FEEDBACK_TYPE_VALUES = [
|
|
||||||
"bug",
|
|
||||||
"feature_request",
|
|
||||||
"confusing_ux",
|
|
||||||
"performance",
|
|
||||||
"other",
|
|
||||||
] as const;
|
|
||||||
const INSTALL_METHOD_VALUES = ["docker", "docker_compose", "source", "cloud", "other"] as const;
|
|
||||||
const USAGE_TYPE_VALUES = [
|
|
||||||
"personal",
|
|
||||||
"team_internal",
|
|
||||||
"business_workflow",
|
|
||||||
"education",
|
|
||||||
"evaluating",
|
|
||||||
] as const;
|
|
||||||
const IMPORTANT_AREA_VALUES = [
|
|
||||||
"images",
|
|
||||||
"pdf_docs",
|
|
||||||
"video_audio",
|
|
||||||
"batch_workflows",
|
|
||||||
"ai_tools",
|
|
||||||
] as const;
|
|
||||||
const FRICTION_AREA_VALUES = [
|
|
||||||
"smooth",
|
|
||||||
"docker",
|
|
||||||
"environment_variables",
|
|
||||||
"auth",
|
|
||||||
"storage",
|
|
||||||
"workers",
|
|
||||||
"ai_tools",
|
|
||||||
"docs",
|
|
||||||
"performance",
|
|
||||||
"other",
|
|
||||||
] as const;
|
|
||||||
const ERROR_CATEGORY_VALUES = [
|
|
||||||
"validation_error",
|
|
||||||
"upload_error",
|
|
||||||
"processing_error",
|
|
||||||
"timeout",
|
|
||||||
"unsupported_format",
|
|
||||||
"worker_unavailable",
|
|
||||||
"unknown",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const toolIdSchema = z
|
const toolIdSchema = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -83,7 +44,7 @@ const feedbackBodySchema = z
|
|||||||
.max(80)
|
.max(80)
|
||||||
.regex(/^[a-z0-9_-]+$/)
|
.regex(/^[a-z0-9_-]+$/)
|
||||||
.optional(),
|
.optional(),
|
||||||
sentiment: z.enum(SENTIMENT_VALUES).optional(),
|
sentiment: z.enum(FEEDBACK_SENTIMENT_VALUES).optional(),
|
||||||
feedbackType: z.enum(FEEDBACK_TYPE_VALUES).optional(),
|
feedbackType: z.enum(FEEDBACK_TYPE_VALUES).optional(),
|
||||||
message: optionalText(2000),
|
message: optionalText(2000),
|
||||||
contactOk: z.boolean().default(false),
|
contactOk: z.boolean().default(false),
|
||||||
@@ -93,11 +54,11 @@ const feedbackBodySchema = z
|
|||||||
toolId: toolIdSchema.optional(),
|
toolId: toolIdSchema.optional(),
|
||||||
searchQuery: optionalText(200),
|
searchQuery: optionalText(200),
|
||||||
jobStatus: z.enum(["completed", "failed"]).optional(),
|
jobStatus: z.enum(["completed", "failed"]).optional(),
|
||||||
installMethod: z.enum(INSTALL_METHOD_VALUES).optional(),
|
installMethod: z.enum(FEEDBACK_INSTALL_METHOD_VALUES).optional(),
|
||||||
usageType: z.enum(USAGE_TYPE_VALUES).optional(),
|
usageType: z.enum(FEEDBACK_USAGE_TYPE_VALUES).optional(),
|
||||||
importantAreas: z.array(z.enum(IMPORTANT_AREA_VALUES)).max(5).optional(),
|
importantAreas: z.array(z.enum(FEEDBACK_IMPORTANT_AREA_VALUES)).max(5).optional(),
|
||||||
frictionArea: z.enum(FRICTION_AREA_VALUES).optional(),
|
frictionArea: z.enum(FEEDBACK_FRICTION_AREA_VALUES).optional(),
|
||||||
errorCategory: z.enum(ERROR_CATEGORY_VALUES).optional(),
|
errorCategory: z.enum(FEEDBACK_ERROR_CATEGORY_VALUES).optional(),
|
||||||
})
|
})
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
const hasText = Boolean(value.message?.trim());
|
const hasText = Boolean(value.message?.trim());
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { I18nProvider } from "./contexts/i18n-context";
|
|||||||
import { useAuth } from "./hooks/use-auth";
|
import { useAuth } from "./hooks/use-auth";
|
||||||
import { useMobile } from "./hooks/use-mobile";
|
import { useMobile } from "./hooks/use-mobile";
|
||||||
import { initAnalytics, isAnalyticsActive, optOut, track } from "./lib/analytics";
|
import { initAnalytics, isAnalyticsActive, optOut, track } from "./lib/analytics";
|
||||||
|
import { AUTH_GUARD_UNGATED_PATHS } from "./lib/auth-routes";
|
||||||
import { useAnalyticsStore } from "./stores/analytics-store";
|
import { useAnalyticsStore } from "./stores/analytics-store";
|
||||||
|
|
||||||
// Lazy-load all pages so each page's JS (and its icons/deps) is only
|
// Lazy-load all pages so each page's JS (and its icons/deps) is only
|
||||||
@@ -100,11 +101,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Don't guard the login or change-password pages
|
// Don't guard the login or change-password pages
|
||||||
if (
|
if (AUTH_GUARD_UNGATED_PATHS.has(location.pathname)) {
|
||||||
location.pathname === "/login" ||
|
|
||||||
location.pathname === "/change-password" ||
|
|
||||||
location.pathname === "/privacy"
|
|
||||||
) {
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,19 @@ import {
|
|||||||
Video,
|
Video,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||||
import { apiGet, apiPut } from "@/lib/api";
|
import { apiGet, apiPut } from "@/lib/api";
|
||||||
|
import { AUTH_GUARD_UNGATED_PATHS } from "@/lib/auth-routes";
|
||||||
import {
|
import {
|
||||||
type FeedbackImportantArea,
|
type FeedbackImportantArea,
|
||||||
type FeedbackUsageType,
|
type FeedbackUsageType,
|
||||||
|
promptVariantForSource,
|
||||||
shouldShowUsageSurvey,
|
shouldShowUsageSurvey,
|
||||||
submitFeedback,
|
submitFeedback,
|
||||||
|
surveyIdForSource,
|
||||||
} from "@/lib/feedback";
|
} from "@/lib/feedback";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||||
@@ -42,7 +46,8 @@ const IMPORTANT_AREAS: { value: FeedbackImportantArea; Icon: typeof Image; wide?
|
|||||||
|
|
||||||
export function UsageSurveyOverlay() {
|
export function UsageSurveyOverlay() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { role } = useAuth();
|
const { role, mustChangePassword } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
const analyticsConfig = useAnalyticsStore((s) => s.config);
|
const analyticsConfig = useAnalyticsStore((s) => s.config);
|
||||||
const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded);
|
const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -51,15 +56,23 @@ export function UsageSurveyOverlay() {
|
|||||||
const [usageType, setUsageType] = useState<FeedbackUsageType | null>(null);
|
const [usageType, setUsageType] = useState<FeedbackUsageType | null>(null);
|
||||||
const [importantAreas, setImportantAreas] = useState<FeedbackImportantArea[]>([]);
|
const [importantAreas, setImportantAreas] = useState<FeedbackImportantArea[]>([]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [dismissing, setDismissing] = useState(false);
|
||||||
|
const busy = submitting || dismissing;
|
||||||
|
const submittedAnswerKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const eligibleAuthState = role === "admin" && !mustChangePassword;
|
||||||
|
const eligibleRoute = !AUTH_GUARD_UNGATED_PATHS.has(location.pathname);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (role !== "admin") return;
|
if (!eligibleAuthState || !eligibleRoute) return;
|
||||||
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
||||||
.then((data) => setSettings(data.settings))
|
.then((data) => setSettings(data.settings))
|
||||||
.catch(() => setSettings({}));
|
.catch(() => setSettings({}));
|
||||||
}, [role]);
|
}, [eligibleAuthState, eligibleRoute]);
|
||||||
|
|
||||||
const visible =
|
const visible =
|
||||||
|
eligibleAuthState &&
|
||||||
|
eligibleRoute &&
|
||||||
settings !== null &&
|
settings !== null &&
|
||||||
shouldShowUsageSurvey({
|
shouldShowUsageSurvey({
|
||||||
settings,
|
settings,
|
||||||
@@ -83,30 +96,45 @@ export function UsageSurveyOverlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleContinue() {
|
async function handleContinue() {
|
||||||
if (!usageType || submitting) return;
|
if (!usageType || busy) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
const answerKey = JSON.stringify({ usageType, importantAreas: [...importantAreas].sort() });
|
||||||
try {
|
try {
|
||||||
await submitFeedback({
|
if (submittedAnswerKeyRef.current !== answerKey) {
|
||||||
source: "onboarding",
|
await submitFeedback({
|
||||||
surveyId: "onboarding-usage-v1",
|
source: "onboarding",
|
||||||
promptVariant: "onboarding-overlay-v1",
|
surveyId: surveyIdForSource("onboarding"),
|
||||||
usageType,
|
promptVariant: promptVariantForSource("onboarding"),
|
||||||
importantAreas,
|
usageType,
|
||||||
});
|
importantAreas,
|
||||||
|
});
|
||||||
|
submittedAnswerKeyRef.current = answerKey;
|
||||||
|
}
|
||||||
await recordSettingsKey("onboarding.usageSurvey.answeredAt");
|
await recordSettingsKey("onboarding.usageSurvey.answeredAt");
|
||||||
} catch {
|
} catch {
|
||||||
// Submission failed (network/auth). Leave the overlay visible so the
|
// Submission failed (network/auth). Leave the overlay visible so the
|
||||||
// admin can retry instead of silently losing their answer.
|
// admin can retry instead of silently losing their answer. If this
|
||||||
|
// exact answer already submitted successfully (submittedAnswerKeyRef
|
||||||
|
// matches), a retry only retries the settings write, so the same
|
||||||
|
// answer never gets submitted twice, but a genuinely different answer
|
||||||
|
// always submits fresh.
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDismiss() {
|
async function handleDismiss() {
|
||||||
// Same reasoning as the handleContinue catch above: a failed write just
|
if (busy) return;
|
||||||
// means the overlay stays visible next time, which is an acceptable,
|
setDismissing(true);
|
||||||
// low-stakes fallback.
|
try {
|
||||||
void recordSettingsKey("onboarding.usageSurvey.dismissedAt").catch(() => {});
|
await recordSettingsKey("onboarding.usageSurvey.dismissedAt");
|
||||||
|
} catch {
|
||||||
|
// Same reasoning as handleContinue's catch: a failed write just means
|
||||||
|
// the overlay stays visible next time, an acceptable low-stakes
|
||||||
|
// fallback.
|
||||||
|
} finally {
|
||||||
|
setDismissing(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
@@ -121,7 +149,10 @@ export function UsageSurveyOverlay() {
|
|||||||
>
|
>
|
||||||
<div className="w-full max-w-md space-y-6">
|
<div className="w-full max-w-md space-y-6">
|
||||||
<div className="flex flex-col items-center text-center gap-3">
|
<div className="flex flex-col items-center text-center gap-3">
|
||||||
<div className="h-11 w-11 rounded-full bg-primary flex items-center justify-center text-xl">
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-11 w-11 rounded-full bg-primary flex items-center justify-center text-xl"
|
||||||
|
>
|
||||||
🦦
|
🦦
|
||||||
</div>
|
</div>
|
||||||
<h1 id="usage-survey-title" className="text-lg font-semibold text-foreground">
|
<h1 id="usage-survey-title" className="text-lg font-semibold text-foreground">
|
||||||
@@ -129,11 +160,18 @@ export function UsageSurveyOverlay() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-labelledby="usage-survey-title"
|
||||||
|
className="grid grid-cols-2 gap-2"
|
||||||
|
>
|
||||||
{USAGE_TYPES.map(({ value, Icon, wide }) => (
|
{USAGE_TYPES.map(({ value, Icon, wide }) => (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: styled button with icon and label acting as an ARIA radio, not a native input
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={usageType === value}
|
||||||
onClick={() => setUsageType(value)}
|
onClick={() => setUsageType(value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
|
"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
|
||||||
@@ -143,24 +181,30 @@ export function UsageSurveyOverlay() {
|
|||||||
wide && "col-span-2 justify-center",
|
wide && "col-span-2 justify-center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
<Icon aria-hidden="true" className="h-4 w-4 shrink-0" />
|
||||||
{t.feedback.usageTypes[value]}
|
{t.feedback.usageTypes[value]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p id="usage-survey-tools-label" className="text-sm font-medium text-foreground">
|
||||||
{t.onboarding.usageSurveyToolsLabel}{" "}
|
{t.onboarding.usageSurveyToolsLabel}{" "}
|
||||||
<span className="text-xs font-normal text-muted-foreground">
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
{t.onboarding.pickAnyHint}
|
{t.onboarding.pickAnyHint}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
{/* biome-ignore lint/a11y/useSemanticElements: plain group wrapper for toggle buttons, a fieldset would disrupt the grid layout */}
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-labelledby="usage-survey-tools-label"
|
||||||
|
className="grid grid-cols-2 gap-2"
|
||||||
|
>
|
||||||
{IMPORTANT_AREAS.map(({ value, Icon, wide }) => (
|
{IMPORTANT_AREAS.map(({ value, Icon, wide }) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-pressed={importantAreas.includes(value)}
|
||||||
onClick={() => toggleArea(value)}
|
onClick={() => toggleArea(value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
|
"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
|
||||||
@@ -170,7 +214,7 @@ export function UsageSurveyOverlay() {
|
|||||||
wide && "col-span-2 justify-center",
|
wide && "col-span-2 justify-center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
<Icon aria-hidden="true" className="h-4 w-4 shrink-0" />
|
||||||
{t.feedback.importantAreas[value]}
|
{t.feedback.importantAreas[value]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -181,7 +225,7 @@ export function UsageSurveyOverlay() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleContinue}
|
onClick={handleContinue}
|
||||||
disabled={!usageType || submitting}
|
disabled={!usageType || busy}
|
||||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t.onboarding.continueLabel}
|
{t.onboarding.continueLabel}
|
||||||
@@ -189,6 +233,7 @@ export function UsageSurveyOverlay() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDismiss}
|
onClick={handleDismiss}
|
||||||
|
disabled={busy}
|
||||||
className="w-full text-center text-xs text-muted-foreground hover:text-foreground hover:underline"
|
className="w-full text-center text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
{t.feedback.dontAskAgain}
|
{t.feedback.dontAskAgain}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Paths where AuthGuard (App.tsx) renders its children without applying
|
||||||
|
// auth checks. Anything mounted globally inside AuthGuard's children (e.g.
|
||||||
|
// UsageSurveyOverlay) must treat these routes as ineligible for logic that
|
||||||
|
// assumes normal auth state, since sessions here may be mid-login,
|
||||||
|
// mid-password-change, or otherwise restricted.
|
||||||
|
export const AUTH_GUARD_UNGATED_PATHS = new Set(["/login", "/change-password", "/privacy"]);
|
||||||
@@ -1,19 +1,28 @@
|
|||||||
|
import type {
|
||||||
|
FeedbackErrorCategory,
|
||||||
|
FeedbackFrictionArea,
|
||||||
|
FeedbackImportantArea,
|
||||||
|
FeedbackInstallMethod,
|
||||||
|
FeedbackSentiment,
|
||||||
|
FeedbackSource,
|
||||||
|
FeedbackSurveyId,
|
||||||
|
FeedbackType,
|
||||||
|
FeedbackUsageType,
|
||||||
|
} from "@snapotter/shared";
|
||||||
import { apiPost } from "@/lib/api";
|
import { apiPost } from "@/lib/api";
|
||||||
|
|
||||||
export type FeedbackSource =
|
export type {
|
||||||
| "global"
|
FeedbackErrorCategory,
|
||||||
| "tool_result"
|
FeedbackFrictionArea,
|
||||||
| "failed_job"
|
FeedbackImportantArea,
|
||||||
| "admin_installer"
|
FeedbackInstallMethod,
|
||||||
| "search_miss"
|
FeedbackSentiment,
|
||||||
| "onboarding";
|
FeedbackSource,
|
||||||
export type FeedbackSurveyId =
|
FeedbackSurveyId,
|
||||||
| "global-feedback-v1"
|
FeedbackType,
|
||||||
| "tool-result-v1"
|
FeedbackUsageType,
|
||||||
| "failed-job-v1"
|
};
|
||||||
| "admin-install-v1"
|
|
||||||
| "search-miss-v1"
|
|
||||||
| "onboarding-usage-v1";
|
|
||||||
export type FeedbackPromptVariant =
|
export type FeedbackPromptVariant =
|
||||||
| "nav-v1"
|
| "nav-v1"
|
||||||
| "inline-v1"
|
| "inline-v1"
|
||||||
@@ -22,40 +31,6 @@ export type FeedbackPromptVariant =
|
|||||||
| "search-empty-v1"
|
| "search-empty-v1"
|
||||||
| "search-results-v1"
|
| "search-results-v1"
|
||||||
| "onboarding-overlay-v1";
|
| "onboarding-overlay-v1";
|
||||||
export type FeedbackSentiment = "great" | "okay" | "issue" | "missing" | "bug" | "idea" | "other";
|
|
||||||
export type FeedbackType = "bug" | "feature_request" | "confusing_ux" | "performance" | "other";
|
|
||||||
export type FeedbackInstallMethod = "docker" | "docker_compose" | "source" | "cloud" | "other";
|
|
||||||
export type FeedbackUsageType =
|
|
||||||
| "personal"
|
|
||||||
| "team_internal"
|
|
||||||
| "business_workflow"
|
|
||||||
| "education"
|
|
||||||
| "evaluating";
|
|
||||||
export type FeedbackImportantArea =
|
|
||||||
| "images"
|
|
||||||
| "pdf_docs"
|
|
||||||
| "video_audio"
|
|
||||||
| "batch_workflows"
|
|
||||||
| "ai_tools";
|
|
||||||
export type FeedbackFrictionArea =
|
|
||||||
| "smooth"
|
|
||||||
| "docker"
|
|
||||||
| "environment_variables"
|
|
||||||
| "auth"
|
|
||||||
| "storage"
|
|
||||||
| "workers"
|
|
||||||
| "ai_tools"
|
|
||||||
| "docs"
|
|
||||||
| "performance"
|
|
||||||
| "other";
|
|
||||||
export type FeedbackErrorCategory =
|
|
||||||
| "validation_error"
|
|
||||||
| "upload_error"
|
|
||||||
| "processing_error"
|
|
||||||
| "timeout"
|
|
||||||
| "unsupported_format"
|
|
||||||
| "worker_unavailable"
|
|
||||||
| "unknown";
|
|
||||||
|
|
||||||
export interface FeedbackPayload {
|
export interface FeedbackPayload {
|
||||||
source: FeedbackSource;
|
source: FeedbackSource;
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Single source of truth for the feedback_submitted event's enum fields.
|
||||||
|
// Consumed by: the API's Zod validation (apps/api/src/routes/feedback.ts),
|
||||||
|
// the API's PostHog event shape (apps/api/src/lib/analytics.ts), and the
|
||||||
|
// web app's feedback types (apps/web/src/lib/feedback.ts). Add new values
|
||||||
|
// here, not in any of those three. They all derive from this file.
|
||||||
|
|
||||||
|
export const FEEDBACK_SOURCE_VALUES = [
|
||||||
|
"global",
|
||||||
|
"tool_result",
|
||||||
|
"failed_job",
|
||||||
|
"admin_installer",
|
||||||
|
"search_miss",
|
||||||
|
"onboarding",
|
||||||
|
] as const;
|
||||||
|
export type FeedbackSource = (typeof FEEDBACK_SOURCE_VALUES)[number];
|
||||||
|
|
||||||
|
export const FEEDBACK_SURVEY_ID_VALUES = [
|
||||||
|
"global-feedback-v1",
|
||||||
|
"tool-result-v1",
|
||||||
|
"failed-job-v1",
|
||||||
|
"admin-install-v1",
|
||||||
|
"search-miss-v1",
|
||||||
|
"onboarding-usage-v1",
|
||||||
|
] as const;
|
||||||
|
export type FeedbackSurveyId = (typeof FEEDBACK_SURVEY_ID_VALUES)[number];
|
||||||
|
|
||||||
|
export const FEEDBACK_SENTIMENT_VALUES = [
|
||||||
|
"great",
|
||||||
|
"okay",
|
||||||
|
"issue",
|
||||||
|
"missing",
|
||||||
|
"bug",
|
||||||
|
"idea",
|
||||||
|
"other",
|
||||||
|
] as const;
|
||||||
|
export type FeedbackSentiment = (typeof FEEDBACK_SENTIMENT_VALUES)[number];
|
||||||
|
|
||||||
|
export const FEEDBACK_TYPE_VALUES = [
|
||||||
|
"bug",
|
||||||
|
"feature_request",
|
||||||
|
"confusing_ux",
|
||||||
|
"performance",
|
||||||
|
"other",
|
||||||
|
] as const;
|
||||||
|
export type FeedbackType = (typeof FEEDBACK_TYPE_VALUES)[number];
|
||||||
|
|
||||||
|
export const FEEDBACK_INSTALL_METHOD_VALUES = [
|
||||||
|
"docker",
|
||||||
|
"docker_compose",
|
||||||
|
"source",
|
||||||
|
"cloud",
|
||||||
|
"other",
|
||||||
|
] as const;
|
||||||
|
export type FeedbackInstallMethod = (typeof FEEDBACK_INSTALL_METHOD_VALUES)[number];
|
||||||
|
|
||||||
|
export const FEEDBACK_USAGE_TYPE_VALUES = [
|
||||||
|
"personal",
|
||||||
|
"team_internal",
|
||||||
|
"business_workflow",
|
||||||
|
"education",
|
||||||
|
"evaluating",
|
||||||
|
] as const;
|
||||||
|
export type FeedbackUsageType = (typeof FEEDBACK_USAGE_TYPE_VALUES)[number];
|
||||||
|
|
||||||
|
export const FEEDBACK_IMPORTANT_AREA_VALUES = [
|
||||||
|
"images",
|
||||||
|
"pdf_docs",
|
||||||
|
"video_audio",
|
||||||
|
"batch_workflows",
|
||||||
|
"ai_tools",
|
||||||
|
] as const;
|
||||||
|
export type FeedbackImportantArea = (typeof FEEDBACK_IMPORTANT_AREA_VALUES)[number];
|
||||||
|
|
||||||
|
export const FEEDBACK_FRICTION_AREA_VALUES = [
|
||||||
|
"smooth",
|
||||||
|
"docker",
|
||||||
|
"environment_variables",
|
||||||
|
"auth",
|
||||||
|
"storage",
|
||||||
|
"workers",
|
||||||
|
"ai_tools",
|
||||||
|
"docs",
|
||||||
|
"performance",
|
||||||
|
"other",
|
||||||
|
] as const;
|
||||||
|
export type FeedbackFrictionArea = (typeof FEEDBACK_FRICTION_AREA_VALUES)[number];
|
||||||
|
|
||||||
|
export const FEEDBACK_ERROR_CATEGORY_VALUES = [
|
||||||
|
"validation_error",
|
||||||
|
"upload_error",
|
||||||
|
"processing_error",
|
||||||
|
"timeout",
|
||||||
|
"unsupported_format",
|
||||||
|
"worker_unavailable",
|
||||||
|
"unknown",
|
||||||
|
] as const;
|
||||||
|
export type FeedbackErrorCategory = (typeof FEEDBACK_ERROR_CATEGORY_VALUES)[number];
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./analytics/baked.js";
|
export * from "./analytics/baked.js";
|
||||||
export * from "./analytics/events.js";
|
export * from "./analytics/events.js";
|
||||||
|
export * from "./analytics/feedback.js";
|
||||||
export * from "./analytics/types.js";
|
export * from "./analytics/types.js";
|
||||||
export * from "./audit-events.js";
|
export * from "./audit-events.js";
|
||||||
export * from "./constants.js";
|
export * from "./constants.js";
|
||||||
|
|||||||
@@ -166,6 +166,43 @@ describe("POST /api/v1/feedback", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts a persona-only onboarding submission with no important areas selected", async () => {
|
||||||
|
process.env.ANALYTICS_BAKED_OVERRIDE = "on";
|
||||||
|
await refreshAnalyticsGate();
|
||||||
|
const token = await loginAsAdmin(testApp.app);
|
||||||
|
|
||||||
|
const res = await testApp.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/feedback",
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: {
|
||||||
|
source: "onboarding",
|
||||||
|
surveyId: "onboarding-usage-v1",
|
||||||
|
promptVariant: "onboarding-overlay-v1",
|
||||||
|
usageType: "personal",
|
||||||
|
importantAreas: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body)).toEqual({ ok: true, accepted: true });
|
||||||
|
expect(captureFeedback).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
source: "onboarding",
|
||||||
|
survey_id: "onboarding-usage-v1",
|
||||||
|
prompt_variant: "onboarding-overlay-v1",
|
||||||
|
usage_type: "personal",
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
// captureFeedback is mocked, so it receives the route's raw properties: the
|
||||||
|
// empty importantAreas array is forwarded as-is. Dropping an empty
|
||||||
|
// important_areas before it reaches PostHog happens inside the real,
|
||||||
|
// unmocked cleanFeedbackProperties (analytics.ts), which this test bypasses.
|
||||||
|
const lastCall = captureFeedback.mock.calls.at(-1);
|
||||||
|
expect(lastCall?.[0].important_areas).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("drops identifying contact fields when contact consent is not checked", async () => {
|
it("drops identifying contact fields when contact consent is not checked", async () => {
|
||||||
process.env.ANALYTICS_BAKED_OVERRIDE = "on";
|
process.env.ANALYTICS_BAKED_OVERRIDE = "on";
|
||||||
await refreshAnalyticsGate();
|
await refreshAnalyticsGate();
|
||||||
|
|||||||
@@ -268,6 +268,27 @@ describe("captureFeedback", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops an empty important_areas array instead of forwarding it", async () => {
|
||||||
|
bakedConfig.enabled = true;
|
||||||
|
bakedConfig.posthogApiKey = "phc_test_key";
|
||||||
|
await mod.initAnalytics();
|
||||||
|
|
||||||
|
await mod.captureFeedback(
|
||||||
|
{
|
||||||
|
source: "onboarding",
|
||||||
|
survey_id: "onboarding-usage-v1",
|
||||||
|
prompt_variant: "onboarding-overlay-v1",
|
||||||
|
contact_ok: false,
|
||||||
|
usage_type: "personal",
|
||||||
|
important_areas: [],
|
||||||
|
},
|
||||||
|
"distinct-empty-areas",
|
||||||
|
);
|
||||||
|
|
||||||
|
const properties = mockCapture.mock.calls.at(-1)?.[0].properties;
|
||||||
|
expect(properties).not.toHaveProperty("important_areas");
|
||||||
|
});
|
||||||
|
|
||||||
it("forwards search_query for a search_miss request", async () => {
|
it("forwards search_query for a search_miss request", async () => {
|
||||||
bakedConfig.enabled = true;
|
bakedConfig.enabled = true;
|
||||||
bakedConfig.posthogApiKey = "phc_test_key";
|
bakedConfig.posthogApiKey = "phc_test_key";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const submitFeedback = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, accepted: true }));
|
const submitFeedback = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, accepted: true }));
|
||||||
@@ -37,48 +38,56 @@ afterEach(() => {
|
|||||||
useAuth.mockReset();
|
useAuth.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function renderOverlay(initialPath = "/") {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[initialPath]}>
|
||||||
|
<UsageSurveyOverlay />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("UsageSurveyOverlay", () => {
|
describe("UsageSurveyOverlay", () => {
|
||||||
it("renders nothing for a non-admin", () => {
|
it("renders nothing for a non-admin", () => {
|
||||||
useAuth.mockReturnValue({ role: "user" });
|
useAuth.mockReturnValue({ role: "user", mustChangePassword: false });
|
||||||
|
|
||||||
render(<UsageSurveyOverlay />);
|
renderOverlay();
|
||||||
|
|
||||||
expect(apiGet).not.toHaveBeenCalled();
|
expect(apiGet).not.toHaveBeenCalled();
|
||||||
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
|
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders nothing once already answered or dismissed", async () => {
|
it("renders nothing once already answered or dismissed", async () => {
|
||||||
useAuth.mockReturnValue({ role: "admin" });
|
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||||
apiGet.mockResolvedValue({
|
apiGet.mockResolvedValue({
|
||||||
settings: { "onboarding.usageSurvey.dismissedAt": "2026-01-01T00:00:00Z" },
|
settings: { "onboarding.usageSurvey.dismissedAt": "2026-01-01T00:00:00Z" },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<UsageSurveyOverlay />);
|
renderOverlay();
|
||||||
|
|
||||||
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||||
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
|
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows both questions for an admin instance that hasn't answered", async () => {
|
it("shows both questions for an admin instance that hasn't answered", async () => {
|
||||||
useAuth.mockReturnValue({ role: "admin" });
|
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||||
apiGet.mockResolvedValue({ settings: {} });
|
apiGet.mockResolvedValue({ settings: {} });
|
||||||
|
|
||||||
render(<UsageSurveyOverlay />);
|
renderOverlay();
|
||||||
|
|
||||||
expect(await screen.findByText("How are you using SnapOtter?")).toBeDefined();
|
expect(await screen.findByText("How are you using SnapOtter?")).toBeDefined();
|
||||||
expect(screen.getByText("What matters most to you?")).toBeDefined();
|
expect(screen.getByText("What matters most to you?")).toBeDefined();
|
||||||
expect(screen.getByRole("button", { name: /Just me/ })).toBeDefined();
|
expect(screen.getByRole("radio", { name: /Just me/ })).toBeDefined();
|
||||||
expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submits the selected answers and records the settings key", async () => {
|
it("submits the selected answers and records the settings key", async () => {
|
||||||
useAuth.mockReturnValue({ role: "admin" });
|
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||||
apiGet.mockResolvedValue({ settings: {} });
|
apiGet.mockResolvedValue({ settings: {} });
|
||||||
|
|
||||||
render(<UsageSurveyOverlay />);
|
renderOverlay();
|
||||||
await screen.findByText("How are you using SnapOtter?");
|
await screen.findByText("How are you using SnapOtter?");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Small team/ }));
|
fireEvent.click(screen.getByRole("radio", { name: /Small team/ }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Images/ }));
|
fireEvent.click(screen.getByRole("button", { name: /Images/ }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /PDF\/docs/ }));
|
fireEvent.click(screen.getByRole("button", { name: /PDF\/docs/ }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||||
@@ -97,11 +106,74 @@ describe("UsageSurveyOverlay", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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: {} });
|
||||||
|
apiPut.mockRejectedValueOnce(new Error("network error")).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
renderOverlay();
|
||||||
|
await screen.findByText("How are you using SnapOtter?");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: /Just me/ }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resubmits feedback if the answer changes after a failed settings write", async () => {
|
||||||
|
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||||
|
apiGet.mockResolvedValue({ settings: {} });
|
||||||
|
apiPut.mockRejectedValueOnce(new Error("network error")).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
renderOverlay();
|
||||||
|
await screen.findByText("How are you using SnapOtter?");
|
||||||
|
|
||||||
|
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/ }));
|
||||||
|
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" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays visible and re-enables Continue if the feedback submission itself fails", async () => {
|
||||||
|
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||||
|
apiGet.mockResolvedValue({ settings: {} });
|
||||||
|
submitFeedback.mockRejectedValueOnce(new Error("network error"));
|
||||||
|
|
||||||
|
renderOverlay();
|
||||||
|
await screen.findByText("How are you using SnapOtter?");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: /Just me/ }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(submitFeedback).toHaveBeenCalledTimes(1));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("button", { name: "Continue" })).not.toBeDisabled(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("How are you using SnapOtter?")).toBeDefined();
|
||||||
|
expect(apiPut).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("dismissing writes the dismiss key without submitting feedback", async () => {
|
it("dismissing writes the dismiss key without submitting feedback", async () => {
|
||||||
useAuth.mockReturnValue({ role: "admin" });
|
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||||
apiGet.mockResolvedValue({ settings: {} });
|
apiGet.mockResolvedValue({ settings: {} });
|
||||||
|
|
||||||
render(<UsageSurveyOverlay />);
|
renderOverlay();
|
||||||
await screen.findByText("How are you using SnapOtter?");
|
await screen.findByText("How are you using SnapOtter?");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Don't ask again" }));
|
fireEvent.click(screen.getByRole("button", { name: "Don't ask again" }));
|
||||||
@@ -113,4 +185,66 @@ describe("UsageSurveyOverlay", () => {
|
|||||||
});
|
});
|
||||||
expect(submitFeedback).not.toHaveBeenCalled();
|
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: {} });
|
||||||
|
let resolveApiPut: (() => void) | undefined;
|
||||||
|
apiPut.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveApiPut = () => resolve({});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderOverlay();
|
||||||
|
await screen.findByText("How are you using SnapOtter?");
|
||||||
|
|
||||||
|
const dismissButton = screen.getByRole("button", { name: "Don't ask again" });
|
||||||
|
fireEvent.click(dismissButton);
|
||||||
|
fireEvent.click(dismissButton);
|
||||||
|
fireEvent.click(dismissButton);
|
||||||
|
|
||||||
|
resolveApiPut?.();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
|
||||||
|
"onboarding.usageSurvey.dismissedAt": expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(apiPut).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when the admin must still change their password", async () => {
|
||||||
|
useAuth.mockReturnValue({ role: "admin", mustChangePassword: true });
|
||||||
|
apiGet.mockResolvedValue({ settings: {} });
|
||||||
|
|
||||||
|
renderOverlay();
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(apiGet).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing on the change-password route even if mustChangePassword is stale-false", async () => {
|
||||||
|
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||||
|
apiGet.mockResolvedValue({ settings: {} });
|
||||||
|
|
||||||
|
renderOverlay("/change-password");
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(apiGet).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing on the privacy policy route", async () => {
|
||||||
|
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
|
||||||
|
apiGet.mockResolvedValue({ settings: {} });
|
||||||
|
|
||||||
|
renderOverlay("/privacy");
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(apiGet).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user