diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx index ff8e3816cb34..e798c2813f15 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx @@ -367,7 +367,7 @@ export const MainNavigation = ({ } }); }, - [router, organization.id, workspace.id] + [router, organization.id] ); const switcherTriggerClasses = cn( diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/NotificationSwitch.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/NotificationSwitch.tsx index 80a6b2217026..0c0a314fb269 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/NotificationSwitch.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/NotificationSwitch.tsx @@ -114,6 +114,7 @@ export const NotificationSwitch = ({ break; } } + // eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount; re-running would re-fire the switch change and toast }, []); return ( diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal.tsx index 75dcd0b0814f..ee41cab00633 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal.tsx @@ -216,6 +216,7 @@ export const AddIntegrationModal = ({ } else { reset(); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-seed the form when edit mode toggles; adding the other deps would reset user edits }, [isEditMode]); const survey = watch("survey"); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/AddIntegrationModal.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/AddIntegrationModal.tsx index c979073e602b..e588806d54b2 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/AddIntegrationModal.tsx @@ -110,6 +110,17 @@ export const AddIntegrationModal = ({ type: dbProperties[fieldKey].type, })) || [] ); + // The effect below re-seeds `selectedDatabase` with a fresh object literal whenever the + // `databases`/`surveys` server props change identity, which an RSC refresh does on unchanged + // content. Keying on the id keeps identical content from recomputing this list. + // + // The trade-off, stated so it is a choice rather than an oversight: if a refresh brings back + // *different* properties for the same database id — someone edited the Notion database's schema + // while this modal was open on it — the list here stays stale until the database is reselected. + // Fixing that by keying on the object trades a rare staleness for a recompute on every refresh; + // fixing it properly means not re-seeding with a fresh literal when the content is unchanged, + // which belongs in the effect rather than in this dep array. + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the database's identity, not the object's }, [selectedDatabase?.id]); const elementItems = useMemo(() => { @@ -155,7 +166,10 @@ export const AddIntegrationModal = ({ })); return [...mappedElements, ...variables, ...hiddenFields, ...Metadata, ...createdAt, ...personAttributes]; - }, [selectedSurvey?.id, contactAttributeKeys]); + // Same as `dbItems` above: `selectedSurvey` is re-seeded from the `surveys` server prop, so its + // identity changes on a refresh that changed nothing. + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the survey's identity, not the object's + }, [contactAttributeKeys, elements, selectedSurvey?.id, t]); useEffect(() => { if (selectedIntegration) { diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/response-filter-context.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/response-filter-context.tsx index ef36601389ee..2129e36754fc 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/response-filter-context.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/response-filter-context.tsx @@ -7,6 +7,7 @@ import { } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/ElementsComboBox"; import { ElementFilterOptions } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/ResponseFilter"; import { getTodayDate } from "@/app/lib/surveys/surveys"; +import { type TDateRangePreset } from "@/lib/date-ranges"; export interface FilterValue { elementType: Partial; @@ -31,6 +32,11 @@ interface SelectedFilterOptions { export interface DateRange { from: Date | undefined; to?: Date; + // Which preset produced this range, if any — set by the preset dropdown, cleared by the manual + // calendar picker. The label is read from here instead of reverse-matching the range, since several + // presets span byte-identical days on period-boundary dates (e.g. "this month" and "last 7 days" on + // the 7th of any month) and cannot be told apart by their bounds alone. + preset?: TDateRangePreset; } interface FilterDateContextProps { diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx index 579e2f4ff621..0c8c1fbd9ab5 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx @@ -48,12 +48,24 @@ export const ResponsePage = ({ const [isFetchingFirstPage, setIsFetchingFirstPage] = useState(false); const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter(); const { t } = useTranslation(); - const filters = useMemo( + const computedFilters = useMemo( () => getFormattedFilters(survey, selectedFilter, dateRange), - [selectedFilter, dateRange] + [survey, selectedFilter, dateRange] ); + // `survey` is an RSC prop, so every `router.refresh()` (survey status dropdown, share modal, reset + // survey) hands down a freshly deserialized object and `computedFilters` takes a new identity for + // unchanged content. Memoized so the serialization happens when the filters actually recompute + // rather than on every render of this page — each scroll fetch, each row or tag edit. + const filtersKey = useMemo(() => JSON.stringify(computedFilters), [computedFilters]); + + // The identity everything downstream keys on, held steady while the filter *value* is unchanged. + // Without it a refresh re-creates `fetchNextPage` and `refetchResponses`, which re-registers the + // analysis refresh handler and would collapse the infinite-scroll list back to page 1. + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the filter value, not its identity + const filters = useMemo(() => computedFilters, [filtersKey]); + const searchParams = useSearchParams(); const fetchNextPage = useCallback(async () => { @@ -107,7 +119,7 @@ export const ResponsePage = ({ } finally { setIsFetchingFirstPage(false); } - }, [filters, responsesPerPage, surveyId]); + }, [filters, responsesPerPage, surveyId, t]); useEffect(() => { return registerAnalysisRefreshHandler(refetchResponses); @@ -162,7 +174,9 @@ export const ResponsePage = ({ }; fetchFilteredResponses(); // page is intentionally omitted to avoid refetching after the initial page setup. - }, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]); + // hasFilters is derived from selectedFilter/dateRange which are already deps. + // eslint-disable-next-line react-hooks/exhaustive-deps -- effect must run only when the filter value changes, not on page updates it sets internally + }, [filtersKey, responsesPerPage, selectedFilter, dateRange, surveyId]); return ( <> diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.tsx index 930929b53698..d0b4d0cd927b 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal.tsx @@ -211,13 +211,13 @@ export const ShareSurveyModal = ({ user.locale, surveyUrl, isReadOnly, - survey.workspaceId, segments, isContactsEnabled, isFormbricksCloud, email, isStorageConfigured, workspaceCustomScripts, + enterpriseLicenseRequestFormUrl, ]); const getDefaultActiveId = useCallback(() => { diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx index 24ff71ac4d26..74d54cc9513a 100755 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/CustomFilter.tsx @@ -1,21 +1,7 @@ "use client"; import * as Sentry from "@sentry/nextjs"; -import { - differenceInDays, - endOfMonth, - endOfQuarter, - endOfYear, - format, - startOfDay, - startOfMonth, - startOfQuarter, - startOfYear, - subDays, - subMonths, - subQuarters, - subYears, -} from "date-fns"; +import { format } from "date-fns"; import { TFunction } from "i18next"; import { Loader2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -29,6 +15,11 @@ import { import { getResponsesDownloadUrlAction } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/actions"; import { downloadResponsesFile } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/utils"; import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys"; +import { + type TDateRangePreset, + resolveDateRangeLabelPreset, + resolveDateRangePresetBounds, +} from "@/lib/date-ranges"; import { useClickOutside } from "@/lib/utils/hooks/useClickOutside"; import { Calendar } from "@/modules/ui/components/calendar"; import { @@ -51,87 +42,49 @@ enum FilterDownload { const getFilterDropDownLabels = (t: TFunction) => ({ ALL_TIME: t("workspace.surveys.summary.all_time"), - LAST_7_DAYS: t("workspace.surveys.summary.last_7_days"), - LAST_30_DAYS: t("workspace.surveys.summary.last_30_days"), - THIS_MONTH: t("workspace.surveys.summary.this_month"), - LAST_MONTH: t("workspace.surveys.summary.last_month"), - LAST_6_MONTHS: t("workspace.surveys.summary.last_6_months"), - THIS_QUARTER: t("workspace.surveys.summary.this_quarter"), - LAST_QUARTER: t("workspace.surveys.summary.last_quarter"), - THIS_YEAR: t("workspace.surveys.summary.this_year"), - LAST_YEAR: t("workspace.surveys.summary.last_year"), CUSTOM_RANGE: t("workspace.surveys.summary.custom_range"), }); +// The relative ranges this filter offers, in dropdown order. Picking one tags `dateRange` with its +// preset, so the trigger label survives a remount without reverse-matching the bounds — several +// presets span byte-identical days on period-boundary dates (on the 30th of a 30-day month, "last 30 +// days" and "this month" cover the same days) and can't be told apart from `{ from, to }` alone. Order +// still breaks that tie for a manually picked custom range that happens to match a preset's bounds. +// What each preset means lives in `@/lib/date-ranges`, shared with the chart time dimension so the +// Summary tab and a chart over the same field agree. +// +// Labels are `t()` calls rather than bare key strings on purpose: the translation-key scanner +// (`packages/i18n-utils`) only counts keys it can see inside a literal `t("…")`, and reports the rest +// as unused. +const DATE_RANGE_PRESETS: readonly { preset: TDateRangePreset; getLabel: (t: TFunction) => string }[] = [ + { preset: "last 7 days", getLabel: (t) => t("workspace.surveys.summary.last_7_days") }, + { preset: "last 30 days", getLabel: (t) => t("workspace.surveys.summary.last_30_days") }, + { preset: "this month", getLabel: (t) => t("workspace.surveys.summary.this_month") }, + { preset: "last month", getLabel: (t) => t("workspace.surveys.summary.last_month") }, + { preset: "this quarter", getLabel: (t) => t("workspace.surveys.summary.this_quarter") }, + { preset: "last quarter", getLabel: (t) => t("workspace.surveys.summary.last_quarter") }, + { preset: "last 6 months", getLabel: (t) => t("workspace.surveys.summary.last_6_months") }, + { preset: "this year", getLabel: (t) => t("workspace.surveys.summary.this_year") }, + { preset: "last year", getLabel: (t) => t("workspace.surveys.summary.last_year") }, +]; + +const DATE_RANGE_PRESET_NAMES = DATE_RANGE_PRESETS.map(({ preset }) => preset); + interface CustomFilterProps { survey: TSurvey; } -const getDateRangeLabel = (from: Date, to: Date, t: TFunction) => { - const dateRanges = [ - { - label: getFilterDropDownLabels(t).LAST_7_DAYS, - matches: () => differenceInDays(to, from) === 7, - }, - { - label: getFilterDropDownLabels(t).LAST_30_DAYS, - matches: () => differenceInDays(to, from) === 30, - }, - { - label: getFilterDropDownLabels(t).THIS_MONTH, - matches: () => - format(from, "yyyy-MM-dd") === format(startOfMonth(new Date()), "yyyy-MM-dd") && - format(to, "yyyy-MM-dd") === format(getTodayDate(), "yyyy-MM-dd"), - }, - { - label: getFilterDropDownLabels(t).LAST_MONTH, - matches: () => - format(from, "yyyy-MM-dd") === format(startOfMonth(subMonths(new Date(), 1)), "yyyy-MM-dd") && - format(to, "yyyy-MM-dd") === format(endOfMonth(subMonths(getTodayDate(), 1)), "yyyy-MM-dd"), - }, - { - label: getFilterDropDownLabels(t).LAST_6_MONTHS, - matches: () => - format(from, "yyyy-MM-dd") === format(startOfMonth(subMonths(new Date(), 6)), "yyyy-MM-dd") && - format(to, "yyyy-MM-dd") === format(endOfMonth(getTodayDate()), "yyyy-MM-dd"), - }, - { - label: getFilterDropDownLabels(t).THIS_QUARTER, - matches: () => - format(from, "yyyy-MM-dd") === format(startOfQuarter(new Date()), "yyyy-MM-dd") && - format(to, "yyyy-MM-dd") === format(endOfQuarter(getTodayDate()), "yyyy-MM-dd"), - }, - { - label: getFilterDropDownLabels(t).LAST_QUARTER, - matches: () => - format(from, "yyyy-MM-dd") === format(startOfQuarter(subQuarters(new Date(), 1)), "yyyy-MM-dd") && - format(to, "yyyy-MM-dd") === format(endOfQuarter(subQuarters(getTodayDate(), 1)), "yyyy-MM-dd"), - }, - { - label: getFilterDropDownLabels(t).THIS_YEAR, - matches: () => - format(from, "yyyy-MM-dd") === format(startOfYear(new Date()), "yyyy-MM-dd") && - format(to, "yyyy-MM-dd") === format(endOfYear(getTodayDate()), "yyyy-MM-dd"), - }, - { - label: getFilterDropDownLabels(t).LAST_YEAR, - matches: () => - format(from, "yyyy-MM-dd") === format(startOfYear(subYears(new Date(), 1)), "yyyy-MM-dd") && - format(to, "yyyy-MM-dd") === format(endOfYear(subYears(getTodayDate(), 1)), "yyyy-MM-dd"), - }, - ]; - - const matchedRange = dateRanges.find((range) => range.matches()); - return matchedRange ? matchedRange.label : getFilterDropDownLabels(t).CUSTOM_RANGE; +const getDateRangeLabel = (dateRange: DateRange, t: TFunction) => { + const preset = resolveDateRangeLabelPreset(dateRange, DATE_RANGE_PRESET_NAMES); + const matched = DATE_RANGE_PRESETS.find((p) => p.preset === preset); + return matched ? matched.getLabel(t) : getFilterDropDownLabels(t).CUSTOM_RANGE; }; export const CustomFilter = ({ survey }: CustomFilterProps) => { const { t } = useTranslation(); const { selectedFilter, dateRange, setDateRange, resetState } = useResponseFilter(); const [filterRange, setFilterRange] = useState( - dateRange.from && dateRange.to - ? getDateRangeLabel(dateRange.from, dateRange.to, t) - : getFilterDropDownLabels(t).ALL_TIME + dateRange.from && dateRange.to ? getDateRangeLabel(dateRange, t) : getFilterDropDownLabels(t).ALL_TIME ); const [selectingDate, setSelectingDate] = useState(DateSelected.FROM); const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); @@ -158,7 +111,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => { const filters = useMemo( () => getFormattedFilters(survey, selectedFilter, dateRange), - [selectedFilter, dateRange] + [survey, selectedFilter, dateRange] ); const datePickerRef = useRef(null); @@ -294,81 +247,16 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => { }}>

{getFilterDropDownLabels(t).ALL_TIME}

- { - setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS); - setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() }); - }}> -

{getFilterDropDownLabels(t).LAST_7_DAYS}

-
- { - setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS); - setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() }); - }}> -

{getFilterDropDownLabels(t).LAST_30_DAYS}

-
- { - setFilterRange(getFilterDropDownLabels(t).THIS_MONTH); - setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() }); - }}> -

{getFilterDropDownLabels(t).THIS_MONTH}

-
- { - setFilterRange(getFilterDropDownLabels(t).LAST_MONTH); - setDateRange({ - from: startOfMonth(subMonths(new Date(), 1)), - to: endOfMonth(subMonths(getTodayDate(), 1)), - }); - }}> -

{getFilterDropDownLabels(t).LAST_MONTH}

-
- { - setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER); - setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) }); - }}> -

{getFilterDropDownLabels(t).THIS_QUARTER}

-
- { - setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER); - setDateRange({ - from: startOfQuarter(subQuarters(new Date(), 1)), - to: endOfQuarter(subQuarters(getTodayDate(), 1)), - }); - }}> -

{getFilterDropDownLabels(t).LAST_QUARTER}

-
- { - setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS); - setDateRange({ - from: startOfMonth(subMonths(new Date(), 6)), - to: endOfMonth(getTodayDate()), - }); - }}> -

{getFilterDropDownLabels(t).LAST_6_MONTHS}

-
- { - setFilterRange(getFilterDropDownLabels(t).THIS_YEAR); - setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) }); - }}> -

{getFilterDropDownLabels(t).THIS_YEAR}

-
- { - setFilterRange(getFilterDropDownLabels(t).LAST_YEAR); - setDateRange({ - from: startOfYear(subYears(new Date(), 1)), - to: endOfYear(subYears(getTodayDate(), 1)), - }); - }}> -

{getFilterDropDownLabels(t).LAST_YEAR}

-
+ {DATE_RANGE_PRESETS.map(({ preset, getLabel }) => ( + { + setFilterRange(getLabel(t)); + setDateRange({ ...resolveDateRangePresetBounds(preset), preset }); + }}> +

{getLabel(t)}

+
+ ))} { setIsDatePickerOpen(true); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/ResponseFilter.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/ResponseFilter.tsx index 97b2e259f217..b0f4f76fc86f 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/ResponseFilter.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/ResponseFilter.tsx @@ -161,6 +161,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => { if (!isOpen) { clearItem(); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- clearItem is recreated each render; effect must fire only when isOpen toggles, not on every render }, [isOpen]); const handleAddNewFilter = () => { diff --git a/apps/web/lib/date-ranges.test.ts b/apps/web/lib/date-ranges.test.ts new file mode 100644 index 000000000000..c73f92cec1b7 --- /dev/null +++ b/apps/web/lib/date-ranges.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "vitest"; +import { + type TDateRangePreset, + isSubDayDateRangePreset, + matchDateRangePreset, + resolveDateRangeLabelPreset, + resolveDateRangePreset, + resolveDateRangePresetBounds, +} from "./date-ranges"; + +// Mid-month, mid-quarter date that exercises month/quarter/year boundaries cleanly. Local time on +// purpose: these ranges describe the viewer's calendar days, and every assertion below builds its +// expected dates the same way, so the suite is timezone-invariant. +const NOW = new Date(2026, 4, 21, 14, 30, 0); // May 21, 2026 14:30 local + +// The presets the survey summary filter offers, in the order it renders them. +const SUMMARY_PRESETS: readonly TDateRangePreset[] = [ + "last 7 days", + "last 30 days", + "this month", + "last month", + "this quarter", + "last quarter", + "last 6 months", + "this year", + "last year", +]; + +describe("resolveDateRangePreset", () => { + test("resolves 'last 7 days' to today plus the six days before it", () => { + expect(resolveDateRangePreset("last 7 days", NOW)).toEqual([ + new Date(2026, 4, 15), + new Date(2026, 4, 21), + ]); + }); + + test("normalizes casing and surrounding whitespace", () => { + expect(resolveDateRangePreset(" Last 7 Days ", NOW)).toEqual(resolveDateRangePreset("last 7 days", NOW)); + }); + + test("returns null for a string that is not a preset", () => { + expect(resolveDateRangePreset("from -3 days to now", NOW)).toBeNull(); + }); +}); + +describe("resolveDateRangePresetBounds", () => { + test("'last 7 days' spans seven whole calendar days ending tonight", () => { + expect(resolveDateRangePresetBounds("last 7 days", NOW)).toEqual({ + from: new Date(2026, 4, 15, 0, 0, 0, 0), + to: new Date(2026, 4, 21, 23, 59, 59, 999), + }); + }); + + test("'last 30 days' spans thirty whole calendar days ending tonight", () => { + expect(resolveDateRangePresetBounds("last 30 days", NOW)).toEqual({ + from: new Date(2026, 3, 22, 0, 0, 0, 0), + to: new Date(2026, 4, 21, 23, 59, 59, 999), + }); + }); + + test("day-granular presets end at the last millisecond of their final day", () => { + expect(resolveDateRangePresetBounds("last month", NOW)).toEqual({ + from: new Date(2026, 3, 1, 0, 0, 0, 0), + to: new Date(2026, 3, 30, 23, 59, 59, 999), + }); + }); + + test("presets covering the current period stop at the end of today, not the end of the period", () => { + expect(resolveDateRangePresetBounds("this year", NOW).to).toEqual(new Date(2026, 4, 21, 23, 59, 59, 999)); + }); + + test("'last 24 hours' keeps its time of day instead of being widened to whole days", () => { + expect(resolveDateRangePresetBounds("last 24 hours", NOW)).toEqual({ + from: new Date(2026, 4, 20, 14, 30, 0), + to: NOW, + }); + }); +}); + +describe("isSubDayDateRangePreset", () => { + test("is true only for presets carrying a time of day", () => { + expect(isSubDayDateRangePreset("last 24 hours")).toBe(true); + expect(isSubDayDateRangePreset("last 7 days")).toBe(false); + expect(isSubDayDateRangePreset("from -3 days to now")).toBe(false); + }); +}); + +describe("matchDateRangePreset", () => { + test("maps every summary preset's own range back to that preset", () => { + for (const preset of SUMMARY_PRESETS) { + const { from, to } = resolveDateRangePresetBounds(preset, NOW); + expect(matchDateRangePreset(from, to, SUMMARY_PRESETS, NOW)).toBe(preset); + } + }); + + test("does not claim a hand-picked range that merely has the same width", () => { + // Seven days wide, but not the seven days ending today — this is a custom range, and labelling it + // "Last 7 days" would misreport which window the numbers on screen cover. + const from = new Date(2026, 0, 1, 0, 0, 0, 0); + const to = new Date(2026, 0, 7, 23, 59, 59, 999); + expect(matchDateRangePreset(from, to, SUMMARY_PRESETS, NOW)).toBeNull(); + }); + + test("matches at day granularity, so a range picked earlier in the day still matches", () => { + const { from } = resolveDateRangePresetBounds("last 7 days", NOW); + const earlierToday = new Date(2026, 4, 21, 9, 15, 0); + expect(matchDateRangePreset(from, earlierToday, SUMMARY_PRESETS, NOW)).toBe("last 7 days"); + }); + + test("returns null when no preset covers the range", () => { + expect( + matchDateRangePreset(new Date(2026, 4, 10), new Date(2026, 4, 12), SUMMARY_PRESETS, NOW) + ).toBeNull(); + }); + + test("cannot tell 'this month' and 'last 7 days' apart on the 7th of a month", () => { + // Both presets end at the end of today by definition, so on the 7th they cover the same seven + // calendar days. This is the collision a stored preset tag is meant to avoid resolving through + // here at all — see `resolveDateRangeLabelPreset`. + const onThe7th = new Date(2026, 7, 7, 10, 0, 0); + const { from, to } = resolveDateRangePresetBounds("this month", onThe7th); + expect(matchDateRangePreset(from, to, SUMMARY_PRESETS, onThe7th)).toBe("last 7 days"); + }); +}); + +describe("resolveDateRangeLabelPreset", () => { + test("prefers the range's own recorded preset over reverse-matching its bounds", () => { + // On the 7th, "this month" and "last 7 days" resolve to identical bounds (see the + // matchDateRangePreset collision test above), so a reverse-match alone can't recover "this + // month" here — the recorded preset is the only thing that can. + const onThe7th = new Date(2026, 7, 7, 10, 0, 0); + const bounds = resolveDateRangePresetBounds("this month", onThe7th); + expect(resolveDateRangeLabelPreset({ ...bounds, preset: "this month" }, SUMMARY_PRESETS, onThe7th)).toBe( + "this month" + ); + }); + + test("falls back to reverse-matching when no preset is recorded, for a hand-picked range", () => { + const { from, to } = resolveDateRangePresetBounds("last 7 days", NOW); + expect(resolveDateRangeLabelPreset({ from, to }, SUMMARY_PRESETS, NOW)).toBe("last 7 days"); + }); + + test("returns null for a range with neither a recorded preset nor a bounds match", () => { + expect( + resolveDateRangeLabelPreset( + { from: new Date(2026, 4, 10), to: new Date(2026, 4, 12) }, + SUMMARY_PRESETS, + NOW + ) + ).toBeNull(); + }); + + test("returns null when the range has no bounds at all (e.g. 'all time')", () => { + expect(resolveDateRangeLabelPreset({}, SUMMARY_PRESETS, NOW)).toBeNull(); + }); +}); diff --git a/apps/web/lib/date-ranges.ts b/apps/web/lib/date-ranges.ts new file mode 100644 index 000000000000..0303e57cbf27 --- /dev/null +++ b/apps/web/lib/date-ranges.ts @@ -0,0 +1,128 @@ +import { + addDays, + endOfDay, + endOfQuarter, + endOfYear, + format, + startOfDay, + startOfMonth, + startOfQuarter, + startOfYear, + subHours, + subMonths, + subQuarters, + subYears, +} from "date-fns"; + +// The one definition of what a relative date range means. Every analytics surface resolves here — +// the survey summary filter, which queries `Response.createdAt` with timestamps, and the chart time +// dimension, which sends date strings to Cube — so "last 7 days" cannot cover one window on the +// Summary tab and a different one in a chart. +// +// Ranges are inclusive on both ends and include the current partial day, the convention every other +// analytics tool follows (GA, Mixpanel, PostHog, ...): "last 7 days" is today plus the six days +// before it, not today plus seven. Cube's native "last N days" strings exclude today, which is why +// chart queries expand these into explicit ranges before they are sent. +const PRESET_RESOLVERS = { + today: (now) => [startOfDay(now), startOfDay(now)], + yesterday: (now) => [addDays(startOfDay(now), -1), addDays(startOfDay(now), -1)], + "last 24 hours": (now) => [subHours(now, 24), now], + "last 7 days": (now) => [addDays(startOfDay(now), -6), startOfDay(now)], + "last 30 days": (now) => [addDays(startOfDay(now), -29), startOfDay(now)], + "this month": (now) => [startOfMonth(now), startOfDay(now)], + "last month": (now) => { + const firstOfThisMonth = startOfMonth(now); + const lastOfLastMonth = addDays(firstOfThisMonth, -1); + return [startOfMonth(lastOfLastMonth), lastOfLastMonth]; + }, + "this quarter": (now) => [startOfQuarter(now), startOfDay(now)], + "last quarter": (now) => { + const lastQuarter = subQuarters(now, 1); + return [startOfQuarter(lastQuarter), endOfQuarter(lastQuarter)]; + }, + "last 6 months": (now) => [startOfDay(subMonths(now, 6)), startOfDay(now)], + "this year": (now) => [startOfYear(now), startOfDay(now)], + "last year": (now) => { + const lastYear = subYears(now, 1); + return [startOfYear(lastYear), endOfYear(lastYear)]; + }, +} satisfies Record [Date, Date]>; + +export type TDateRangePreset = keyof typeof PRESET_RESOLVERS; + +// Sub-day presets carry a time of day; the rest are calendar-day ranges and are widened to whole +// days by the consumers that need real instants. +const SUB_DAY_PRESETS: ReadonlySet = new Set(["last 24 hours"]); + +const isDateRangePreset = (value: string): value is TDateRangePreset => + Object.hasOwn(PRESET_RESOLVERS, value); + +export const isSubDayDateRangePreset = (preset: string): boolean => { + const key = preset.toLowerCase().trim(); + return isDateRangePreset(key) && SUB_DAY_PRESETS.has(key); +}; + +/** + * Resolves a preset name to its raw `[start, end]` pair, or `null` for anything that is not a known + * preset — the shape Cube's query expansion needs, where the incoming string may be a preset, an + * explicit range, or one of Cube's own expressions. + */ +export const resolveDateRangePreset = (preset: string, now: Date = new Date()): [Date, Date] | null => { + const key = preset.toLowerCase().trim(); + return isDateRangePreset(key) ? PRESET_RESOLVERS[key](now) : null; +}; + +/** + * Resolves a preset to the absolute instants that bound it, for callers that filter on real + * timestamps rather than date strings. Cube widens a bare `yyyy-MM-dd` end to 23:59:59.999 itself; a + * Prisma `lte` does not, so day-granular presets are widened to whole days here. + */ +export const resolveDateRangePresetBounds = ( + preset: TDateRangePreset, + now: Date = new Date() +): { from: Date; to: Date } => { + const [start, end] = PRESET_RESOLVERS[preset](now); + return SUB_DAY_PRESETS.has(preset) + ? { from: start, to: end } + : { from: startOfDay(start), to: endOfDay(end) }; +}; + +/** + * Finds the first preset covering exactly the same calendar days as `[from, to]`, for a range that + * arrived with no preset attached (a manually picked custom range) — callers that pick a preset from + * a list should keep that preset alongside the range instead of relying on this to recover it. Once + * every calendar-period preset ends at "today", several presets become genuinely indistinguishable by + * their bounds on period-boundary days (e.g. "this month" and "last 7 days" on the 7th of any month, + * or "last 30 days" and "this month" on the 30th of a 30-day month) — matching is day-granular, and + * `presets` order picks a winner among those ties, silently mislabeling the other. + */ +export const matchDateRangePreset = ( + from: Date, + to: Date, + presets: readonly TDateRangePreset[], + now: Date = new Date() +): TDateRangePreset | null => { + const day = (date: Date): string => format(date, "yyyy-MM-dd"); + return ( + presets.find((preset) => { + const bounds = resolveDateRangePresetBounds(preset, now); + return day(bounds.from) === day(from) && day(bounds.to) === day(to); + }) ?? null + ); +}; + +/** + * Resolves the preset that should label `range` for display: the explicit `preset` it was tagged + * with, if any, otherwise a reverse-match of its bounds against `presets`. A caller that already + * knows which preset produced a range (e.g. a dropdown selection) should tag the range with it and + * pass that through here, rather than relying on the bounds alone — those can be genuinely ambiguous + * (see `matchDateRangePreset`), so the tag is the only reliable source once it exists. + */ +export const resolveDateRangeLabelPreset = ( + range: { from?: Date; to?: Date; preset?: TDateRangePreset }, + presets: readonly TDateRangePreset[], + now: Date = new Date() +): TDateRangePreset | null => { + if (range.preset) return range.preset; + return range.from && range.to ? matchDateRangePreset(range.from, range.to, presets, now) : null; +}; diff --git a/apps/web/lib/useDocumentVisibility.test.ts b/apps/web/lib/useDocumentVisibility.test.ts new file mode 100644 index 000000000000..19081130af94 --- /dev/null +++ b/apps/web/lib/useDocumentVisibility.test.ts @@ -0,0 +1,65 @@ +/** + * @vitest-environment jsdom + */ +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { useDocumentVisibility } from "./useDocumentVisibility"; + +const setVisibilityState = (state: DocumentVisibilityState) => + vi.spyOn(document, "visibilityState", "get").mockReturnValue(state); + +const fireVisibilityChange = () => document.dispatchEvent(new Event("visibilitychange")); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("useDocumentVisibility", () => { + test("calls onVisible when the document becomes visible", () => { + setVisibilityState("visible"); + const onVisible = vi.fn(); + renderHook(() => useDocumentVisibility(onVisible)); + + fireVisibilityChange(); + + expect(onVisible).toHaveBeenCalledTimes(1); + }); + + test("does not call onVisible while the document is hidden", () => { + setVisibilityState("hidden"); + const onVisible = vi.fn(); + renderHook(() => useDocumentVisibility(onVisible)); + + fireVisibilityChange(); + + expect(onVisible).not.toHaveBeenCalled(); + }); + + test("invokes the latest callback after a re-render, exactly once", () => { + setVisibilityState("visible"); + const first = vi.fn(); + const second = vi.fn(); + + const { rerender } = renderHook(({ onVisible }) => useDocumentVisibility(onVisible), { + initialProps: { onVisible: first }, + }); + + rerender({ onVisible: second }); + fireVisibilityChange(); + + // Called once, not twice: a re-render must not leave a second active listener behind. + expect(second).toHaveBeenCalledTimes(1); + expect(first).not.toHaveBeenCalled(); + }); + + test("stops listening after unmount", () => { + setVisibilityState("visible"); + const onVisible = vi.fn(); + const { unmount } = renderHook(() => useDocumentVisibility(onVisible)); + + unmount(); + fireVisibilityChange(); + + expect(onVisible).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/lib/useDocumentVisibility.ts b/apps/web/lib/useDocumentVisibility.ts index 9254cf079ca2..9681cc690d43 100644 --- a/apps/web/lib/useDocumentVisibility.ts +++ b/apps/web/lib/useDocumentVisibility.ts @@ -1,11 +1,18 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; // This hook will listen to the visibilitychange event and run the provided function whenever the document's visibility state changes to visible export const useDocumentVisibility = (onVisible: () => void) => { + // Keep the latest callback in a ref so the listener always calls the current + // `onVisible` without having to re-subscribe on every render. + const onVisibleRef = useRef(onVisible); + useEffect(() => { + onVisibleRef.current = onVisible; + }, [onVisible]); + useEffect(() => { const listener = () => { if (document.visibilityState === "visible") { - onVisible(); + onVisibleRef.current(); } }; diff --git a/apps/web/modules/api/v2/management/responses/route.test.ts b/apps/web/modules/api/v2/management/responses/route.test.ts index 35824d4dcb81..9c1958e92be4 100644 --- a/apps/web/modules/api/v2/management/responses/route.test.ts +++ b/apps/web/modules/api/v2/management/responses/route.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import type { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; +// Imported statically rather than with `await import("./route")` inside a test: `vi.mock` is hoisted +// above imports either way, but a dynamic import charges the route graph's first transform to +// whichever test runs first, which on a loaded CI runner exceeded the 5s testTimeout. +import { GET } from "./route"; const { mockAuthenticatedApiClient, mockGetResponses, mockHandleApiError, mockSuccessResponse } = vi.hoisted( () => ({ @@ -85,7 +89,6 @@ describe("GET /management/responses", () => { }, }); - const { GET } = await import("./route"); const response = await GET(buildRequest() as any); const body = await response.json(); @@ -103,7 +106,6 @@ describe("GET /management/responses", () => { error: { type: "internal_server_error", details: [{ field: "responses", issue: "boom" }] }, }); - const { GET } = await import("./route"); const response = await GET(buildRequest() as any); expect(mockSuccessResponse).not.toHaveBeenCalled(); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.test.ts index bbeae5da9325..28d4e8333180 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import type { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; +import { DELETE, PUT } from "./route"; const { mockAuthenticatedApiClient, @@ -86,7 +87,6 @@ describe("PUT/DELETE /organizations/[organizationId]/teams/[teamId]", () => { mockGetApiKeyCreatorRole.mockResolvedValue(null); mockCanManageOrganizationUsers.mockReturnValue(false); - const { DELETE } = await import("./route"); const response = await DELETE(buildRequest("DELETE"), { params: Promise.resolve({ organizationId, teamId }), }); @@ -107,7 +107,6 @@ describe("PUT/DELETE /organizations/[organizationId]/teams/[teamId]", () => { mockCanManageOrganizationUsers.mockReturnValue(true); mockDeleteTeam.mockResolvedValue({ ok: true, data: team }); - const { DELETE } = await import("./route"); const response = await DELETE(buildRequest("DELETE"), { params: Promise.resolve({ organizationId, teamId }), }); @@ -123,7 +122,6 @@ describe("PUT/DELETE /organizations/[organizationId]/teams/[teamId]", () => { mockGetApiKeyCreatorRole.mockResolvedValue(null); mockCanManageOrganizationUsers.mockReturnValue(false); - const { PUT } = await import("./route"); const response = await PUT(buildRequest("PUT"), { params: Promise.resolve({ organizationId, teamId }), }); @@ -144,7 +142,6 @@ describe("PUT/DELETE /organizations/[organizationId]/teams/[teamId]", () => { mockCanManageOrganizationUsers.mockReturnValue(true); mockUpdateTeam.mockResolvedValue({ ok: true, data: { ...team, name: "Renamed Team" } }); - const { PUT } = await import("./route"); const response = await PUT(buildRequest("PUT"), { params: Promise.resolve({ organizationId, teamId }), }); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.test.ts index 1506274f685c..b5a8c49582cd 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import type { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; +import { POST } from "./route"; const { mockAuthenticatedApiClient, @@ -79,7 +80,6 @@ describe("POST /organizations/[organizationId]/teams", () => { mockGetApiKeyCreatorRole.mockResolvedValue(null); mockCanManageOrganizationUsers.mockReturnValue(false); - const { POST } = await import("./route"); const response = await POST(buildRequest(), { params: Promise.resolve({ organizationId }) }); expect(mockGetApiKeyCreatorRole).toHaveBeenCalledWith(apiKeyId, organizationId); @@ -98,7 +98,6 @@ describe("POST /organizations/[organizationId]/teams", () => { mockCanManageOrganizationUsers.mockReturnValue(true); mockCreateTeam.mockResolvedValue({ ok: true, data: { id: "team123", ...teamInput, organizationId } }); - const { POST } = await import("./route"); const response = await POST(buildRequest(), { params: Promise.resolve({ organizationId }) }); expect(mockGetApiKeyCreatorRole).toHaveBeenCalledWith(apiKeyId, organizationId); diff --git a/apps/web/modules/auth/signup/components/password-checks.tsx b/apps/web/modules/auth/signup/components/password-checks.tsx index b081afc90392..c4e8b2730517 100644 --- a/apps/web/modules/auth/signup/components/password-checks.tsx +++ b/apps/web/modules/auth/signup/components/password-checks.tsx @@ -25,11 +25,14 @@ const ValidationIcon = ({ state }: { state: boolean }) => export const PasswordChecks = ({ password }: PasswordChecksProps) => { const { t } = useTranslation(); - const DEFAULT_VALIDATIONS = [ - { label: t("auth.signup.password_validation_uppercase_and_lowercase"), state: false }, - { label: t("auth.signup.password_validation_minimum_8_and_maximum_128_characters"), state: false }, - { label: t("auth.signup.password_validation_contain_at_least_1_number"), state: false }, - ]; + const DEFAULT_VALIDATIONS = useMemo( + () => [ + { label: t("auth.signup.password_validation_uppercase_and_lowercase"), state: false }, + { label: t("auth.signup.password_validation_minimum_8_and_maximum_128_characters"), state: false }, + { label: t("auth.signup.password_validation_contain_at_least_1_number"), state: false }, + ], + [t] + ); const validations = useMemo(() => { if (password === null) return DEFAULT_VALIDATIONS; @@ -48,7 +51,7 @@ export const PasswordChecks = ({ password }: PasswordChecksProps) => { state: PASSWORD_REGEX.NUMBER.test(password), }, ]; - }, [password]); + }, [password, DEFAULT_VALIDATIONS, t]); return (
diff --git a/apps/web/modules/ee/analysis/api/lib/cube-client.test.ts b/apps/web/modules/ee/analysis/api/lib/cube-client.test.ts index a44e545248fb..a43f18a38701 100644 --- a/apps/web/modules/ee/analysis/api/lib/cube-client.test.ts +++ b/apps/web/modules/ee/analysis/api/lib/cube-client.test.ts @@ -166,7 +166,7 @@ describe("executeTenantScopedQuery", () => { expect(result).toEqual(rows); }); - test("keeps a synthesized empty date bucket at 0 but preserves a real null in the same result", async () => { + test("leaves a ratio measure null in a synthesized empty date bucket, alongside a real null", async () => { const DAY = "FeedbackRecords.collectedAt.day"; // The pivot invents 2026-01-03 to fill the gap; 2026-01-02 is a day Cube returned, where the // measure is genuinely NULL (responses that day, none of them answering this question). @@ -192,8 +192,40 @@ describe("executeTenantScopedQuery", () => { { [DAY]: "2026-01-01", "FeedbackRecords.npsScore": "72.92" }, // real bucket, nothing to compute → no data { [DAY]: "2026-01-02", "FeedbackRecords.npsScore": null }, - // bucket the pivot invented → a measured zero - { [DAY]: "2026-01-03", "FeedbackRecords.npsScore": 0 }, + // Invented bucket: nobody answered, so there is no NPS to report. A 0 here is a real score + // (as many detractors as promoters) and would pull the line down to the baseline. + { [DAY]: "2026-01-03", "FeedbackRecords.npsScore": null }, + ]); + }); + + test("splits an invented bucket by measure: the count is 0, the ratio beside it stays null", async () => { + const DAY = "FeedbackRecords.collectedAt.day"; + mockTablePivot.mockImplementation((pivotConfig?: { fillMissingDates?: boolean }) => { + const real = [{ [DAY]: "2026-01-01", "FeedbackRecords.count": 12, "FeedbackRecords.npsScore": "50" }]; + if (pivotConfig?.fillMissingDates === false) return real; + return [ + ...real, + { + [DAY]: "2026-01-02", + "FeedbackRecords.count": "__formbricks_null__", + "FeedbackRecords.npsScore": "__formbricks_null__", + }, + ]; + }); + + const { executeTenantScopedQuery } = await import("./cube-client"); + const result = await executeTenantScopedQuery({ + ...scopedInput, + query: { + measures: ["FeedbackRecords.count", "FeedbackRecords.npsScore"], + timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", granularity: "day" }], + }, + }); + + // One empty day, two answers: it genuinely collected zero responses, and it has no NPS at all. + expect(result).toEqual([ + { [DAY]: "2026-01-01", "FeedbackRecords.count": 12, "FeedbackRecords.npsScore": "50" }, + { [DAY]: "2026-01-02", "FeedbackRecords.count": 0, "FeedbackRecords.npsScore": null }, ]); }); diff --git a/apps/web/modules/ee/analysis/api/lib/cube-client.ts b/apps/web/modules/ee/analysis/api/lib/cube-client.ts index 6356e9357a09..cfd73ac8c2cc 100644 --- a/apps/web/modules/ee/analysis/api/lib/cube-client.ts +++ b/apps/web/modules/ee/analysis/api/lib/cube-client.ts @@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"; import { logger } from "@formbricks/logger"; import type { TChartQuery } from "@formbricks/types/analysis"; import { expandPresetDateRanges } from "@/modules/ee/analysis/lib/date-presets"; +import { isRatioMeasure } from "@/modules/ee/analysis/lib/schema-definition"; import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis"; import { queueAuditEventWithoutRequest } from "@/modules/ee/audit-logs/lib/handler"; import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; @@ -82,8 +83,8 @@ const restoreNullMeasures = ( rows: TChartDataRow[], measureKeys: string[], /** - * Rows this rejects were invented by the pivot to fill an empty date bucket, so their filled cells - * are a measured zero rather than a NULL. Defaults to treating every row as real. + * Rows this rejects were invented by the pivot to fill an empty date bucket, so their filled + * count cells are a measured zero rather than a NULL. Defaults to treating every row as real. */ isRealRow: (row: TChartDataRow) => boolean = () => true ): TChartDataRow[] => { @@ -97,9 +98,15 @@ const restoreNullMeasures = ( const filled = Object.keys(row).filter((key) => row[key] === NULL_FILL_SENTINEL && measures.has(key)); if (filled.length === 0) return row; - const replacement = isRealRow(row) ? null : 0; + const rowIsReal = isRealRow(row); const restored = { ...row }; - for (const key of filled) restored[key] = replacement; + for (const key of filled) { + // An invented bucket counted zero of everything, but it has no ratio: there is no NPS for a + // day nobody answered, and zeroing it plots a real score of 0 (equal promoters and + // detractors) on every empty day, dragging the line to the baseline between the days that + // do have answers. Ratios stay null there so the line breaks instead. + restored[key] = rowIsReal || isRatioMeasure(key) ? null : 0; + } return restored; }); }; diff --git a/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx b/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx index 0bda2c60e236..0738dabd29d4 100644 --- a/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx +++ b/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx @@ -8,6 +8,7 @@ import { FiltersPanel } from "@/modules/ee/analysis/charts/components/filters-pa import { MeasuresPanel } from "@/modules/ee/analysis/charts/components/measures-panel"; import { TimeDimensionPanel } from "@/modules/ee/analysis/charts/components/time-dimension-panel"; import { useChartQuery } from "@/modules/ee/analysis/charts/hooks/use-chart-query"; +import { prepareQueryForChartType } from "@/modules/ee/analysis/charts/lib/big-number"; import { type ChartBuilderState, type FilterRow, @@ -80,8 +81,10 @@ const chartBuilderReducer = (state: ChartBuilderState, action: Action): ChartBui } }; -const toComparableQueryJson = (query: TChartQuery): string => - JSON.stringify(buildCubeQuery({ ...initialState, ...parseQueryToState(query) })); +const toComparableQueryJson = (query: TChartQuery, chartType: TChartType): string => + JSON.stringify( + prepareQueryForChartType(buildCubeQuery({ ...initialState, ...parseQueryToState(query) }), chartType) + ); export function AdvancedChartBuilder({ workspaceId, @@ -117,13 +120,19 @@ export function AdvancedChartBuilder({ const timeDimensionOpen = state.timeDimension != null; const filtersOpen = state.filters.length > 0; - const currentQuery = useMemo(() => buildCubeQuery(state), [state]); + // The executed query depends on the chart type as well as the form: a big number has nowhere to + // put groups, so its query drops them (see prepareQueryForChartType). Switching the chart type + // therefore changes the query and re-runs it, rather than re-rendering stale grouped rows. + const currentQuery = useMemo( + () => prepareQueryForChartType(buildCubeQuery(state), chartType), + [state, chartType] + ); const currentQueryJson = JSON.stringify(currentQuery); // The last query that was executed (or arrived pre-executed via initialQuery, e.g. from the // AI section or a saved chart). Auto-run only fires when the form drifts away from it. const lastRunQueryJsonRef = useRef( - initialQuery ? toComparableQueryJson(initialQuery) : null + initialQuery ? toComparableQueryJson(initialQuery, chartType) : null ); const appliedInitialQueryRef = useRef(null); @@ -132,10 +141,14 @@ export function AdvancedChartBuilder({ if (appliedInitialQueryRef.current === initialQuery) return; appliedInitialQueryRef.current = initialQuery; const parsed = parseQueryToState(initialQuery); - lastRunQueryJsonRef.current = JSON.stringify(buildCubeQuery({ ...initialState, ...parsed })); + lastRunQueryJsonRef.current = JSON.stringify( + prepareQueryForChartType(buildCubeQuery({ ...initialState, ...parsed }), chartType) + ); dispatch({ type: ACTION.INIT_FROM_QUERY, payload: parsed }); setDimensionsOpen((parsed.selectedDimensions?.length ?? 0) > 0); - }, [initialQuery]); + // chartType only feeds the baseline above; a later switch is caught by the guard and left to + // drift detection, which is what re-runs the query for the new type. + }, [initialQuery, chartType]); // Incomplete configs (no measure yet, half-filled filter row) are skipped silently instead of // surfacing validation toasts on every keystroke; the preview keeps its last valid state. diff --git a/apps/web/modules/ee/analysis/charts/components/chart-dropdown-menu.tsx b/apps/web/modules/ee/analysis/charts/components/chart-dropdown-menu.tsx index 51cfbc1eb745..c03c2a53fee0 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-dropdown-menu.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-dropdown-menu.tsx @@ -79,7 +79,7 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly { cancelled = true; }; - }, [isAddToDashboardDialogOpen, workspaceId, chart.id]); + }, [isAddToDashboardDialogOpen, workspaceId, chart.id, t]); const handleDeleteChart = async () => { setIsDeleting(true); diff --git a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx index a668971e4bd8..e88336e77f80 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx @@ -8,6 +8,7 @@ import { cn } from "@/lib/cn"; import { BreakdownBars } from "@/modules/ee/analysis/charts/components/breakdown-bars"; import { CartesianChart } from "@/modules/ee/analysis/charts/components/cartesian-chart"; import { PolishedChartTooltip } from "@/modules/ee/analysis/charts/components/polished-tooltip"; +import { computeBigNumberValue } from "@/modules/ee/analysis/charts/lib/big-number"; import { resolveChartDisplay } from "@/modules/ee/analysis/charts/lib/chart-display"; import { CHART_BRAND_DARK, @@ -545,18 +546,13 @@ export function ChartRenderer({ ); case "big_number": { // A measure with nothing to compute comes back as NULL (see restoreNullMeasures in - // cube-client, which maps the pivot's sentinel back to null). Summing it as 0 would print a - // confident "0" for "never asked", so count the numeric rows and fall back to a no-data glyph. - const numericValues = data - .map((row) => row[dataKey]) - .filter((value) => value !== null && value !== undefined && value !== "") - .map(Number) - .filter((value) => Number.isFinite(value)); - const hasValue = numericValues.length > 0; - const total = numericValues.reduce((sum, value) => sum + value, 0); + // cube-client, which maps the pivot's sentinel back to null). Printing it as 0 would be a + // confident "0" for "never asked", so fall back to a no-data glyph instead. + const value = computeBigNumberValue(data, dataKey); + const hasValue = value !== null; // formatCellValue caps at two fraction digits, so a big number and a bar label now agree on // precision instead of showing 4.705 next to 4.7. - const formatted = hasValue ? formatCellValue(total) : NO_DATA_PLACEHOLDER; + const formatted = hasValue ? formatCellValue(value) : NO_DATA_PLACEHOLDER; return (
diff --git a/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts b/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts index 119755563229..968595cdddb0 100644 --- a/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts +++ b/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts @@ -13,6 +13,7 @@ import { getChartAction, updateChartAction, } from "@/modules/ee/analysis/charts/actions"; +import { prepareQueryForChartType } from "@/modules/ee/analysis/charts/lib/big-number"; import { sanitizeChartDisplay } from "@/modules/ee/analysis/charts/lib/chart-display"; import { resolveChartType } from "@/modules/ee/analysis/charts/lib/chart-utils"; import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions"; @@ -127,9 +128,12 @@ export function useChartDialog({ setChartConfig(chart.config ?? {}); setSelectedDirectoryId(chart.feedbackDirectoryId); + // Charts saved before a big number's query stopped carrying groups can still hold a + // granularity or a dimension; normalize on read so the value shown is the measure over the + // whole range, not a fold of per-group values. const queryResult = await executeQueryAction({ workspaceId, - query: chart.query, + query: prepareQueryForChartType(chart.query, resolveChartType(chart.type)), feedbackDirectoryId: chart.feedbackDirectoryId, }); if (cancelled) return; diff --git a/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.server.ts b/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.server.ts index eb843d15d15e..ff697db46f21 100644 --- a/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.server.ts +++ b/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.server.ts @@ -11,6 +11,7 @@ import { } from "@/modules/ee/analysis/lib/schema-definition"; import { type TChartType, ZChartType } from "@/modules/ee/analysis/types/analysis"; import { getAIChartPromptError } from "./ai-chart-errors.server"; +import { prepareQueryForChartType } from "./big-number"; const CUBE_NAME = "FeedbackRecords"; const DEFAULT_MEASURE = `${CUBE_NAME}.count`; @@ -173,7 +174,12 @@ const normalizeChartQuery = (output: AIQueryResponse): AIChartQueryResult => { })); } - const result: AIChartQueryResult = { chartType: output.chartType, query }; + // A big number has no axis for a grouping, so the model asking for one (a granularity, a + // dimension) is dropped rather than folded into the single value it renders. + const result: AIChartQueryResult = { + chartType: output.chartType, + query: prepareQueryForChartType(query, output.chartType), + }; const name = output.name?.trim(); if (name) { diff --git a/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.test.ts b/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.test.ts index 9c7da5a248d9..d549b2c864ec 100644 --- a/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.test.ts +++ b/apps/web/modules/ee/analysis/charts/lib/ai-chart-query.test.ts @@ -91,6 +91,42 @@ describe("generateAIChartQuery", () => { }); }); + test("strips a grouping the model put on a big number, which has no axis for it", async () => { + mocks.generateOrganizationAIObject.mockResolvedValueOnce({ + object: { + name: null, + measures: ["FeedbackRecords.npsScore"], + dimensions: ["FeedbackRecords.sourceName"], + timeDimensions: [ + { + dimension: "FeedbackRecords.collectedAt", + granularity: "day", + dateRange: "last 30 days", + }, + ], + chartType: "big_number", + filters: null, + }, + }); + + const result = await generateAIChartQuery({ + organizationId: "organization-1", + workspaceId: "workspace-1", + userId: "user-1", + prompt: "NPS score for the last 30 days as a big number", + }); + + // The date range survives as a filter; the grouping it was paired with does not, because the + // single value cannot be recovered from per-day NPS readings. + expect(result).toEqual({ + chartType: "big_number", + query: { + measures: ["FeedbackRecords.npsScore"], + timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", dateRange: "last 30 days" }], + }, + }); + }); + test("falls back to the total count measure when the AI returns no measures", async () => { mocks.generateOrganizationAIObject.mockResolvedValueOnce({ object: { diff --git a/apps/web/modules/ee/analysis/charts/lib/big-number.test.ts b/apps/web/modules/ee/analysis/charts/lib/big-number.test.ts new file mode 100644 index 000000000000..23e1f59b652e --- /dev/null +++ b/apps/web/modules/ee/analysis/charts/lib/big-number.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "vitest"; +import type { TChartQuery } from "@formbricks/types/analysis"; +import { + computeBigNumberValue, + prepareQueryForChartType, + toSingleValueQuery, +} from "@/modules/ee/analysis/charts/lib/big-number"; + +describe("toSingleValueQuery", () => { + test("drops the time granularity but keeps the date range, which is a filter", () => { + const query: TChartQuery = { + measures: ["FeedbackRecords.npsScore"], + timeDimensions: [ + { + dimension: "FeedbackRecords.collectedAt", + granularity: "day", + dateRange: "last 30 days", + }, + ], + }; + + expect(toSingleValueQuery(query)).toEqual({ + measures: ["FeedbackRecords.npsScore"], + timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", dateRange: "last 30 days" }], + }); + }); + + test("drops dimensions and order, and keeps filters", () => { + const query: TChartQuery = { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.sourceName"], + order: [["FeedbackRecords.sourceName", "asc"]], + filters: [{ member: "FeedbackRecords.fieldType", operator: "equals", values: ["nps"] }], + }; + + expect(toSingleValueQuery(query)).toEqual({ + measures: ["FeedbackRecords.count"], + filters: [{ member: "FeedbackRecords.fieldType", operator: "equals", values: ["nps"] }], + }); + }); + + test("leaves a query that already returns one row untouched", () => { + const query: TChartQuery = { measures: ["FeedbackRecords.npsScore"] }; + expect(toSingleValueQuery(query)).toEqual(query); + }); +}); + +describe("prepareQueryForChartType", () => { + const grouped: TChartQuery = { + measures: ["FeedbackRecords.npsScore"], + timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", granularity: "day" }], + }; + + test("normalizes a big number", () => { + expect(prepareQueryForChartType(grouped, "big_number")).toEqual({ + measures: ["FeedbackRecords.npsScore"], + timeDimensions: [{ dimension: "FeedbackRecords.collectedAt" }], + }); + }); + + test.each(["line", "area", "bar", "pie"] as const)("leaves a %s chart grouped", (chartType) => { + expect(prepareQueryForChartType(grouped, chartType)).toBe(grouped); + }); +}); + +describe("computeBigNumberValue", () => { + test("reads the value off the single row a normalized query returns", () => { + expect(computeBigNumberValue([{ "FeedbackRecords.npsScore": "51.85" }], "FeedbackRecords.npsScore")).toBe( + 51.85 + ); + }); + + test("refuses to fold a ratio spread over several rows", () => { + // A chart saved before the query was normalized can still arrive grouped. Adding these gave + // 1350 for a period whose real NPS was 51.85, so the caller shows a no-data glyph instead. + const rows = [ + { "FeedbackRecords.npsScore": "100.00" }, + { "FeedbackRecords.npsScore": "-100.00" }, + { "FeedbackRecords.npsScore": "100.00" }, + ]; + expect(computeBigNumberValue(rows, "FeedbackRecords.npsScore")).toBeNull(); + }); + + test("adds up a count across rows, which is additive", () => { + const rows = [{ "FeedbackRecords.count": "3" }, { "FeedbackRecords.count": "2" }]; + expect(computeBigNumberValue(rows, "FeedbackRecords.count")).toBe(5); + }); + + test("returns null when the measure had nothing to compute", () => { + expect( + computeBigNumberValue([{ "FeedbackRecords.npsScore": null }], "FeedbackRecords.npsScore") + ).toBeNull(); + expect(computeBigNumberValue([], "FeedbackRecords.npsScore")).toBeNull(); + }); + + test("keeps a real zero, which is a score and not a missing value", () => { + expect(computeBigNumberValue([{ "FeedbackRecords.npsScore": "0.00" }], "FeedbackRecords.npsScore")).toBe( + 0 + ); + }); + + test("skips non-numeric cells rather than counting them as zero", () => { + const rows = [ + { "FeedbackRecords.count": "4" }, + { "FeedbackRecords.count": "n/a" }, + { "FeedbackRecords.count": "" }, + ]; + expect(computeBigNumberValue(rows, "FeedbackRecords.count")).toBe(4); + }); +}); diff --git a/apps/web/modules/ee/analysis/charts/lib/big-number.ts b/apps/web/modules/ee/analysis/charts/lib/big-number.ts new file mode 100644 index 000000000000..106bb36ee936 --- /dev/null +++ b/apps/web/modules/ee/analysis/charts/lib/big-number.ts @@ -0,0 +1,50 @@ +import type { TChartQuery } from "@formbricks/types/analysis"; +import { isRatioMeasure } from "@/modules/ee/analysis/lib/schema-definition"; +import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis"; + +/** + * A big number renders one value and has no axis to put groups on, but the builder's grouping + * panels stay live when the chart type is switched — so a big number can carry a time granularity + * or a dimension it cannot show, and the renderer is left with N rows to fold into one. + * + * There is no fold that works: adding the per-group values of a ratio is meaningless (a week of + * daily NPS readings of 100 and -100 summed to 1350 where the period's real NPS was 51.85), and + * averaging them is wrong too, since each day carries a different number of responses. Even a count + * cannot always be added — the same person answering on two days is one unique respondent, not two. + * + * So the grouping is dropped from the query instead and Cube recomputes each measure over the whole + * range, which is right for every measure type. The date range stays: it is a filter, not a + * grouping. `order` goes with the grouping, since it may name a member that is no longer selected + * and there is only one row left to order. + */ +export const toSingleValueQuery = (query: TChartQuery): TChartQuery => { + const { dimensions: _dimensions, order: _order, ...rest } = query; + const timeDimensions = query.timeDimensions?.map(({ granularity: _granularity, ...timeDim }) => timeDim); + return timeDimensions ? { ...rest, timeDimensions } : rest; +}; + +/** Apply the query normalization a chart type needs. Only big numbers need one today. */ +export const prepareQueryForChartType = (query: TChartQuery, chartType: TChartType): TChartQuery => + chartType === "big_number" ? toSingleValueQuery(query) : query; + +const toFiniteNumber = (value: unknown): number | null => { + if (value === null || value === undefined || value === "") return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; +}; + +/** + * The single value a big number shows, or null when there is none to show (the caller renders a + * no-data glyph rather than a confident number). + * + * With {@link toSingleValueQuery} applied there is exactly one row, so this is that row's value. It + * still folds several rows for an additive measure, but refuses to for a ratio: charts saved before + * the normalization existed can still arrive grouped, and a summed NPS is worse than no number. + */ +export const computeBigNumberValue = (rows: TChartDataRow[], measureKey: string): number | null => { + const values = rows.map((row) => toFiniteNumber(row[measureKey])).filter((v): v is number => v !== null); + if (values.length === 0) return null; + if (values.length === 1) return values[0]; + if (isRatioMeasure(measureKey)) return null; + return values.reduce((sum, value) => sum + value, 0); +}; diff --git a/apps/web/modules/ee/analysis/dashboards/pages/dashboard-detail-page.tsx b/apps/web/modules/ee/analysis/dashboards/pages/dashboard-detail-page.tsx index c66ae874e803..1fe8c1aaef3e 100644 --- a/apps/web/modules/ee/analysis/dashboards/pages/dashboard-detail-page.tsx +++ b/apps/web/modules/ee/analysis/dashboards/pages/dashboard-detail-page.tsx @@ -7,6 +7,8 @@ import { getAISmartToolsUnavailableReason, getOrganizationAIConfig } from "@/lib import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getTranslate } from "@/lingodotdev/server"; import { executeTenantScopedQuery } from "@/modules/ee/analysis/api/lib/cube-client"; +import { prepareQueryForChartType } from "@/modules/ee/analysis/charts/lib/big-number"; +import { resolveChartType } from "@/modules/ee/analysis/charts/lib/chart-utils"; import { resolveOptionGrouping } from "@/modules/ee/analysis/charts/lib/option-grouping"; import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout"; import { checkFeedbackDirectoryAccess } from "@/modules/ee/analysis/lib/access"; @@ -149,7 +151,12 @@ export async function DashboardDetailPage({ widgetDataPromises.set( widget.id, executeWidgetQuery( - applyDashboardDateFilter(widget.chart.query, dateFilter), + // A big number's query is normalized here too, not only in the builder: widgets saved + // before that existed still carry a grouping the single value cannot come from. + prepareQueryForChartType( + applyDashboardDateFilter(widget.chart.query, dateFilter), + resolveChartType(widget.chart.type) + ), widget.chart.feedbackDirectoryId, workspaceId, organization.id, diff --git a/apps/web/modules/ee/analysis/lib/date-presets.test.ts b/apps/web/modules/ee/analysis/lib/date-presets.test.ts index 29d461d40bd2..834a3abc75fe 100644 --- a/apps/web/modules/ee/analysis/lib/date-presets.test.ts +++ b/apps/web/modules/ee/analysis/lib/date-presets.test.ts @@ -1,6 +1,8 @@ +import { formatDate } from "date-fns"; import { describe, expect, test } from "vitest"; import type { TChartQuery } from "@formbricks/types/analysis"; -import { expandPresetDateRanges } from "./date-presets"; +import { isSubDayDateRangePreset, resolveDateRangePresetBounds } from "@/lib/date-ranges"; +import { DASHBOARD_DATE_PRESETS, expandPresetDateRanges } from "./date-presets"; const queryWithDateRange = (dateRange: string | [string, string]): TChartQuery => ({ measures: ["FeedbackRecords.count"], @@ -116,4 +118,17 @@ describe("expandPresetDateRanges", () => { expandPresetDateRanges(q, NOW); expect(JSON.stringify(q)).toBe(before); }); + + // A chart and the survey summary filter set to the same preset must cover the same days, which only + // holds while both read their ranges from `@/lib/date-ranges`. Redefine either side and this fails. + test.each(DASHBOARD_DATE_PRESETS.filter((preset) => !isSubDayDateRangePreset(preset)))( + "'%s' covers the same days as the summary filter", + (preset) => { + const { from, to } = resolveDateRangePresetBounds(preset, NOW); + expect(expandPresetDateRanges(queryWithDateRange(preset), NOW).timeDimensions?.[0].dateRange).toEqual([ + formatDate(from, "yyyy-MM-dd"), + formatDate(to, "yyyy-MM-dd"), + ]); + } + ); }); diff --git a/apps/web/modules/ee/analysis/lib/date-presets.ts b/apps/web/modules/ee/analysis/lib/date-presets.ts index 0fc9427d015d..e3336aa8c945 100644 --- a/apps/web/modules/ee/analysis/lib/date-presets.ts +++ b/apps/web/modules/ee/analysis/lib/date-presets.ts @@ -1,65 +1,26 @@ -import { - addDays, - endOfQuarter, - endOfYear, - formatDate, - startOfDay, - startOfMonth, - startOfQuarter, - startOfYear, - subHours, - subMonths, - subQuarters, - subYears, -} from "date-fns"; +import { formatDate } from "date-fns"; import type { TChartQuery } from "@formbricks/types/analysis"; +import { isSubDayDateRangePreset, resolveDateRangePreset } from "@/lib/date-ranges"; -// Cube's native "last N days" / "this month" / etc. strings exclude today; we expand them -// to explicit inclusive ranges so charts behave like every other analytics tool (GA, Mixpanel, -// PostHog, ...) and include the current partial day. -const PRESET_RESOLVERS: Record [Date, Date]> = { - today: (now) => [startOfDay(now), startOfDay(now)], - yesterday: (now) => [addDays(startOfDay(now), -1), addDays(startOfDay(now), -1)], - "last 24 hours": (now) => [subHours(now, 24), now], - "last 7 days": (now) => [addDays(startOfDay(now), -6), startOfDay(now)], - "last 30 days": (now) => [addDays(startOfDay(now), -29), startOfDay(now)], - "this month": (now) => [startOfMonth(now), startOfDay(now)], - "last month": (now) => { - const firstOfThisMonth = startOfMonth(now); - const lastOfLastMonth = addDays(firstOfThisMonth, -1); - return [startOfMonth(lastOfLastMonth), lastOfLastMonth]; - }, - "this quarter": (now) => [startOfQuarter(now), startOfDay(now)], - "last quarter": (now) => { - const lastQuarter = subQuarters(now, 1); - return [startOfQuarter(lastQuarter), endOfQuarter(lastQuarter)]; - }, - "last 6 months": (now) => [startOfDay(subMonths(now, 6)), startOfDay(now)], - "this year": (now) => [startOfYear(now), startOfDay(now)], - "last year": (now) => { - const lastYear = subYears(now, 1); - return [startOfYear(lastYear), endOfYear(lastYear)]; - }, -}; - -// Sub-day presets need timestamp precision; day-granular presets stay date-only so charts keep -// their existing calendar-day behavior. -const TIME_PRECISION_PRESETS = new Set(["last 24 hours"]); - +// Cube's native "last N days" / "this month" / etc. strings exclude today; we expand them to the +// explicit inclusive ranges defined in `@/lib/date-ranges` — shared with the survey summary filter so +// both surfaces mean the same window — which include the current partial day the way every other +// analytics tool does (GA, Mixpanel, PostHog, ...). export const expandPresetDateRanges = (query: TChartQuery, now: Date = new Date()): TChartQuery => { if (!query.timeDimensions?.length) return query; const expanded = query.timeDimensions.map((td) => { - if (typeof td.dateRange !== "string") return td; - const key = td.dateRange.toLowerCase().trim(); - const resolver = PRESET_RESOLVERS[key]; - if (!resolver) return td; - const [start, end] = resolver(now); + const preset = td.dateRange; + if (typeof preset !== "string") return td; + const range = resolveDateRangePreset(preset, now); + if (!range) return td; + const [start, end] = range; // Sub-day presets serialize as UTC ISO 8601 (with the `Z` offset, milliseconds truncated) so the // same instant produces the same string regardless of the server's timezone — Cube reads these - // bare timestamps as UTC. Day-granular presets stay date-only, keeping their calendar-day meaning. + // bare timestamps as UTC. Day-granular presets stay date-only, keeping their calendar-day meaning + // (Cube widens a date-only end to 23:59:59.999 itself). const serialize = (date: Date): string => - TIME_PRECISION_PRESETS.has(key) + isSubDayDateRangePreset(preset) ? `${date.toISOString().slice(0, 19)}Z` : formatDate(date, "yyyy-MM-dd"); return { @@ -71,9 +32,9 @@ export const expandPresetDateRanges = (query: TChartQuery, now: Date = new Date( return { ...query, timeDimensions: expanded }; }; -// Ordered preset list for the dashboard-level date filter. "all time" and "custom" are not -// resolvers — they are handled by the filter UI / override helper — so they live only in the -// component, not here. Values must match PRESET_RESOLVERS keys so they expand consistently. +// Ordered preset list for the dashboard-level date filter. "all time" and "custom" are not presets — +// they are handled by the filter UI / override helper — so they live only in the component, not here. +// Values must match the preset names in `@/lib/date-ranges` so they expand consistently. export const DASHBOARD_DATE_PRESETS = [ "last 24 hours", "last 7 days", diff --git a/apps/web/modules/ee/analysis/lib/schema-definition.test.ts b/apps/web/modules/ee/analysis/lib/schema-definition.test.ts index ed0329806a7b..7c77f38fada6 100644 --- a/apps/web/modules/ee/analysis/lib/schema-definition.test.ts +++ b/apps/web/modules/ee/analysis/lib/schema-definition.test.ts @@ -19,6 +19,7 @@ import { getTranslatedFieldLabel, isEnrichmentDimensionId, isNotEnrichedDimensionValue, + isRatioMeasure, isSelectableValueDimension, sortMeasureIdsForCategoryAxis, sortRowsByEnumDimension, @@ -144,6 +145,23 @@ describe("schema-definition", () => { expect(getMeasureById("FeedbackRecords.npsScore")?.group).toBe("score"); }); + test("classifies scores and averages as ratios, and counts as additive", () => { + // Drives two decisions that must not diverge: whether an empty bucket means 0 or "no value", + // and whether per-group values may be folded into one. + expect(isRatioMeasure("FeedbackRecords.npsScore")).toBe(true); + expect(isRatioMeasure("FeedbackRecords.csatScore")).toBe(true); + expect(isRatioMeasure("FeedbackRecords.npsAverage")).toBe(true); + expect(isRatioMeasure("FeedbackRecords.sentimentAverage")).toBe(true); + + expect(isRatioMeasure("FeedbackRecords.count")).toBe(false); + expect(isRatioMeasure("FeedbackRecords.promoterCount")).toBe(false); + expect(isRatioMeasure("FeedbackRecords.uniqueRespondents")).toBe(false); + + // Not a measure, and not in the schema at all: both keep the additive default. + expect(isRatioMeasure("FeedbackRecords.sourceName")).toBe(false); + expect(isRatioMeasure("FeedbackRecords.notAMeasure")).toBe(false); + }); + test("only exposes members present in the deployed Cube schema", () => { const chartCubeSchema = readChartCubeSchema(); const exposedMembers = [...FEEDBACK_FIELDS.measures, ...FEEDBACK_FIELDS.dimensions].map(({ id }) => diff --git a/apps/web/modules/ee/analysis/lib/schema-definition.ts b/apps/web/modules/ee/analysis/lib/schema-definition.ts index ef8851ff9061..9b138660a8c3 100644 --- a/apps/web/modules/ee/analysis/lib/schema-definition.ts +++ b/apps/web/modules/ee/analysis/lib/schema-definition.ts @@ -396,6 +396,19 @@ export const FEEDBACK_MEASURE_IDS: string[] = FEEDBACK_FIELDS.measures.map((m) = export const getMeasureAxisMaxCandidates = (measureId: string): readonly number[] | undefined => FEEDBACK_FIELDS.measures.find((m) => m.id === measureId)?.axisMaxCandidates; +/** + * True for a measure computed *over* the responses in a group — a score or an average — rather + * than by counting them. The distinction decides what an empty group means: a count genuinely + * counted zero there, while a ratio has nothing to divide, so it has no value at all. It also + * decides whether per-group values can be folded into one, since ratios cannot be added. + * + * False for anything not in the schema, so an unrecognized column keeps the additive treatment. + */ +export const isRatioMeasure = (measureId: string): boolean => { + const group = FEEDBACK_FIELDS.measures.find((m) => m.id === measureId)?.group; + return group === "score" || group === "average"; +}; + export const FEEDBACK_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions.map((d) => d.id); export const FEEDBACK_TIME_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions diff --git a/apps/web/modules/ee/contacts/attributes/components/attributes-table.tsx b/apps/web/modules/ee/contacts/attributes/components/attributes-table.tsx index b861ad893382..ad8f22b7b7b1 100644 --- a/apps/web/modules/ee/contacts/attributes/components/attributes-table.tsx +++ b/apps/web/modules/ee/contacts/attributes/components/attributes-table.tsx @@ -137,6 +137,7 @@ export const AttributesTable = ({ console.error(err); } } + // eslint-disable-next-line react-hooks/exhaustive-deps -- loads persisted column settings once per workspace; `table` is an unstable ref and would clobber user changes on every render }, [workspaceId]); // Hide select column when all attributes are system attributes diff --git a/apps/web/modules/ee/contacts/components/contact-data-view.tsx b/apps/web/modules/ee/contacts/components/contact-data-view.tsx index f3d5bd60deb1..a8a96547fb5d 100644 --- a/apps/web/modules/ee/contacts/components/contact-data-view.tsx +++ b/apps/web/modules/ee/contacts/components/contact-data-view.tsx @@ -43,11 +43,20 @@ export const ContactDataView = ({ prevWorkspaceId.current = workspaceId; setContacts([...initialContacts]); setHasMore(initialHasMore); - isResettingSearch.current = true; + // Arm the skip only when there is a search to clear. `setSearchValue("")` on an already-empty + // value is a no-op, so the `[searchValue]` effect never runs to lower the flag again — and the + // next time the user does type, that effect consumes their first search as the "reset" it was + // waiting for. Switching workspace without having searched is the common path, so the flag was + // usually left armed. + if (searchValue) { + isResettingSearch.current = true; + } setSearchValue(""); prevInitialContactsLength.current = initialContacts.length; } - }, [workspaceId, initialContacts, initialHasMore]); + // `searchValue` is read above, so it belongs here; the whole body is behind the workspace-change + // guard, which updates `prevWorkspaceId` immediately, so the extra runs are a comparison. + }, [workspaceId, initialContacts, initialHasMore, searchValue]); // Sync state when initialContacts changes from server refresh (e.g., after CSV upload) // Only update if we're viewing the first page without search @@ -105,6 +114,7 @@ export const ContactDataView = ({ if (isResettingSearch.current) { isResettingSearch.current = false; } + // eslint-disable-next-line react-hooks/exhaustive-deps -- debounced search must fire only on searchValue change; `fetchContactsFromStart` is keyed on `searchValue` itself, so listing it would re-trigger the search whenever `workspaceId` or `itemsPerPage` changed too }, [searchValue]); useEffect(() => { diff --git a/apps/web/modules/ee/contacts/components/contacts-table.tsx b/apps/web/modules/ee/contacts/components/contacts-table.tsx index c7716c88e878..b34374d048d1 100644 --- a/apps/web/modules/ee/contacts/components/contacts-table.tsx +++ b/apps/web/modules/ee/contacts/components/contacts-table.tsx @@ -142,6 +142,7 @@ export const ContactsTable = ({ if (savedExpandedSettings !== null) { setIsExpanded(JSON.parse(savedExpandedSettings)); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- loads persisted column settings once per workspace; `table` and `data` change every render and would clobber user changes }, [workspaceId]); // Save settings to localStorage when they change diff --git a/apps/web/modules/survey/components/element-form-input/components/recall-wrapper.tsx b/apps/web/modules/survey/components/element-form-input/components/recall-wrapper.tsx index 0a7cc19c5fc5..7618f71aab5b 100644 --- a/apps/web/modules/survey/components/element-form-input/components/recall-wrapper.tsx +++ b/apps/web/modules/survey/components/element-form-input/components/recall-wrapper.tsx @@ -242,6 +242,7 @@ export const RecallWrapper = ({ }; setRenderedText(processInput()); + // eslint-disable-next-line react-hooks/exhaustive-deps -- re-render only on text/recall changes; `filterRecallItems` mutates internalValue/recallItems and would loop, localSurvey/usedLanguageCode are captured via those }, [internalValue, recallItems]); return ( diff --git a/apps/web/modules/survey/components/element-form-input/index.tsx b/apps/web/modules/survey/components/element-form-input/index.tsx index 07561d23b152..324e8d8346ca 100644 --- a/apps/web/modules/survey/components/element-form-input/index.tsx +++ b/apps/web/modules/survey/components/element-form-input/index.tsx @@ -119,7 +119,7 @@ export const ElementFormInput = ({ : isEndingCard ? localSurvey.endings[elementIdx - elements.length].id : currentElement.id; - }, [isWelcomeCard, isEndingCard, currentElement?.id]); + }, [isWelcomeCard, isEndingCard, currentElement?.id, elementIdx, elements.length, localSurvey.endings]); const endingCard = localSurvey.endings.find((ending) => ending.id === elementId); const surveyLanguageCodes = useMemo( diff --git a/apps/web/modules/survey/components/template-list/components/template-tags.tsx b/apps/web/modules/survey/components/template-list/components/template-tags.tsx index d3b684c52783..b6fa26fd0e15 100644 --- a/apps/web/modules/survey/components/template-list/components/template-tags.tsx +++ b/apps/web/modules/survey/components/template-list/components/template-tags.tsx @@ -2,7 +2,7 @@ import { TFunction } from "i18next"; import { SplitIcon } from "lucide-react"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { TTemplate, TTemplateFilter, TTemplateRole } from "@formbricks/types/templates"; import { TWorkspaceConfigChannel, TWorkspaceConfigIndustry } from "@formbricks/types/workspace"; @@ -71,25 +71,28 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) => const roleTag = useMemo( () => getRoleMapping(t).find((roleMap) => roleMap.value === template.role)?.label, - [template.role] + [template.role, t] ); - const channelTag = useMemo(() => getChannelTag(template.channels, t), [template.channels]); - const getIndustryTag = (industries: TWorkspaceConfigIndustry[] | undefined): string | undefined => { - // if user selects an industry e.g. eCommerce than the tag should not say "Multiple industries" anymore but "E-Commerce". - if (selectedFilter[1] !== null) { - const industry = getIndustryMapping(t).find((industry) => industry.value === selectedFilter[1]); - if (industry) return industry.label; - } - if (!industries || industries.length === 0) return undefined; - return industries.length > 1 - ? t("workspace.surveys.templates.multiple_industries") - : getIndustryMapping(t).find((industry) => industry.value === industries[0])?.label; - }; + const channelTag = useMemo(() => getChannelTag(template.channels, t), [template.channels, t]); + const getIndustryTag = useCallback( + (industries: TWorkspaceConfigIndustry[] | undefined): string | undefined => { + // if user selects an industry e.g. eCommerce than the tag should not say "Multiple industries" anymore but "E-Commerce". + if (selectedFilter[1] !== null) { + const industry = getIndustryMapping(t).find((industry) => industry.value === selectedFilter[1]); + if (industry) return industry.label; + } + if (!industries || industries.length === 0) return undefined; + return industries.length > 1 + ? t("workspace.surveys.templates.multiple_industries") + : getIndustryMapping(t).find((industry) => industry.value === industries[0])?.label; + }, + [selectedFilter, t] + ); const industryTag = useMemo( () => getIndustryTag(template.industries), - [template.industries, selectedFilter] + [template.industries, getIndustryTag] ); return ( diff --git a/apps/web/modules/survey/editor/components/address-element-form.tsx b/apps/web/modules/survey/editor/components/address-element-form.tsx index 697ed7bb9699..5eb85cc3ae8f 100644 --- a/apps/web/modules/survey/editor/components/address-element-form.tsx +++ b/apps/web/modules/survey/editor/components/address-element-form.tsx @@ -82,6 +82,7 @@ export const AddressElementForm = ({ .every((field) => !field.required); updateElement(elementIdx, { required: !allFieldsAreOptional }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- sync-only effect; adding updateElement/elementIdx would loop as it writes back into the survey }, [element.addressLine1, element.addressLine2, element.city, element.state, element.zip, element.country]); const [parent] = useAutoAnimate(); diff --git a/apps/web/modules/survey/editor/components/cal-element-form.tsx b/apps/web/modules/survey/editor/components/cal-element-form.tsx index 76806d7c69ac..828501efe9cc 100644 --- a/apps/web/modules/survey/editor/components/cal-element-form.tsx +++ b/apps/web/modules/survey/editor/components/cal-element-form.tsx @@ -44,6 +44,7 @@ export const CalElementForm = ({ } else { updateElement(elementIdx, { calHost: element.calHost ?? "cal.com" }); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only reacts to the toggle; adding element.calHost/updateElement/elementIdx would loop as it writes calHost back into the survey }, [isCalHostEnabled]); return ( diff --git a/apps/web/modules/survey/editor/components/contact-info-element-form.tsx b/apps/web/modules/survey/editor/components/contact-info-element-form.tsx index b98ab02dfa80..17ed5a2ddaa7 100644 --- a/apps/web/modules/survey/editor/components/contact-info-element-form.tsx +++ b/apps/web/modules/survey/editor/components/contact-info-element-form.tsx @@ -78,6 +78,7 @@ export const ContactInfoElementForm = ({ .every((field) => !field.required); updateElement(elementIdx, { required: !allFieldsAreOptional }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- sync-only effect; adding updateElement/elementIdx would loop as it writes back into the survey }, [element.firstName, element.lastName, element.email, element.phone, element.company]); const [parent] = useAutoAnimate(); diff --git a/apps/web/modules/survey/editor/components/elements-view.tsx b/apps/web/modules/survey/editor/components/elements-view.tsx index a61798b6fc8c..f39fc07f5c1b 100644 --- a/apps/web/modules/survey/editor/components/elements-view.tsx +++ b/apps/web/modules/survey/editor/components/elements-view.tsx @@ -803,7 +803,7 @@ export const ElementsView = ({ setActiveElementId(elementWithEmptyFallback.id); toast.error(t("workspace.surveys.edit.fallback_missing")); } - }, [activeElementId, setActiveElementId, localSurvey, selectedLanguageCode]); + }, [activeElementId, setActiveElementId, localSurvey, selectedLanguageCode, t]); const sensors = useSensors( useSensor(PointerSensor, { diff --git a/apps/web/modules/survey/editor/components/survey-editor.tsx b/apps/web/modules/survey/editor/components/survey-editor.tsx index 063122ef8366..6ea51c881d23 100644 --- a/apps/web/modules/survey/editor/components/survey-editor.tsx +++ b/apps/web/modules/survey/editor/components/survey-editor.tsx @@ -119,19 +119,15 @@ export const SurveyEditor = ({ useDocumentVisibility(fetchLatestWorkspaceData); + // Recovery only: `localSurvey` is seeded from `survey` in its `useState` initializer, so this is a + // no-op unless something ever resets it to null (the `LoadingSkeleton` guard below is the state it + // recovers from). Written as an updater rather than reading `localSurvey`, because depending on it + // would re-run this on every keystroke in the editor to do nothing — and that shape becomes a real + // loop the moment someone edits the guard. The active element is not set here: the + // `[localSurvey?.type]` effect below already picks the first element whenever `localSurvey` + // appears. useEffect(() => { - if (survey) { - if (localSurvey) return; - - const surveyClone = structuredClone(survey); - setLocalSurvey(surveyClone); - - // Set first element from first block - const firstBlock = survey.blocks[0]; - if (firstBlock) { - setActiveElementId(firstBlock.elements?.[0]?.id); - } - } + setLocalSurvey((current) => current ?? structuredClone(survey)); }, [survey]); useEffect(() => { @@ -158,6 +154,7 @@ export const SurveyEditor = ({ if (firstBlock) { setActiveElementId(firstBlock.elements[0]?.id); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally resets active element only when the survey type changes, not on every block edit }, [localSurvey?.type]); useEffect(() => { diff --git a/apps/web/modules/survey/follow-ups/components/follow-up-item.tsx b/apps/web/modules/survey/follow-ups/components/follow-up-item.tsx index 1bd2c28c9b8f..5487162c2ca3 100644 --- a/apps/web/modules/survey/follow-ups/components/follow-up-item.tsx +++ b/apps/web/modules/survey/follow-ups/components/follow-up-item.tsx @@ -117,7 +117,7 @@ export const FollowUpItem = ({ ...prev, followUps: [...prev.followUps, newFollowUp], })); - }, [followUp, setLocalSurvey]); + }, [followUp, setLocalSurvey, t]); return ( <> diff --git a/apps/web/modules/survey/link/components/survey-client-wrapper.tsx b/apps/web/modules/survey/link/components/survey-client-wrapper.tsx index 95a45dfb18f5..64a77b9e95ca 100644 --- a/apps/web/modules/survey/link/components/survey-client-wrapper.tsx +++ b/apps/web/modules/survey/link/components/survey-client-wrapper.tsx @@ -123,6 +123,10 @@ export const SurveyClientWrapper = ({ } }, []); + // Serialized so the memo below is keyed on the field ids' contents rather than the array identity, + // which changes on every parent render (see ENG-2366). + const hiddenFieldIdsKey = JSON.stringify(survey.hiddenFields.fieldIds || []); + // Extract hidden fields from URL parameters const hiddenFieldsRecord = useMemo(() => { const fieldsRecord: Record = {}; @@ -131,8 +135,8 @@ export const SurveyClientWrapper = ({ if (answer) fieldsRecord[field] = answer; } return fieldsRecord; - // eslint-disable-next-line react-hooks/use-memo -- migration ENG-2366 - }, [searchParams, JSON.stringify(survey.hiddenFields.fieldIds || [])]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on hiddenFieldIdsKey so the record recomputes on field-id content changes, not on every new array instance + }, [searchParams, hiddenFieldIdsKey]); // Include verified email in hidden fields if available const getVerifiedEmail = useMemo | null>(() => { diff --git a/apps/web/modules/survey/multi-language-surveys/components/manage-translations-modal.tsx b/apps/web/modules/survey/multi-language-surveys/components/manage-translations-modal.tsx index 687cb26d3e08..65d25b0e4091 100644 --- a/apps/web/modules/survey/multi-language-surveys/components/manage-translations-modal.tsx +++ b/apps/web/modules/survey/multi-language-surveys/components/manage-translations-modal.tsx @@ -109,6 +109,7 @@ export const ManageTranslationsModal = ({ if (!aEmpty && bEmpty) return 1; return 0; }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- `isDraftEmpty` is intentionally excluded so rows don't re-sort on every keystroke; the order snapshots on strings/missingFirst changes }, [strings, missingFirst]); // Merge draft translations into localSurvey so that the recall dropdown diff --git a/apps/web/modules/ui/components/connect-integration/index.tsx b/apps/web/modules/ui/components/connect-integration/index.tsx index defbcb2c9116..9dcd2bc14f47 100644 --- a/apps/web/modules/ui/components/connect-integration/index.tsx +++ b/apps/web/modules/ui/components/connect-integration/index.tsx @@ -44,6 +44,7 @@ export const ConnectIntegration = ({ if (error) { toast.error(t("workspace.integrations.connecting_integration_failed_please_try_again")); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount to surface an OAuth error query param; re-running on searchParams changes would re-fire the toast }, []); return ( diff --git a/apps/web/modules/ui/components/editor/components/toolbar-plugin.tsx b/apps/web/modules/ui/components/editor/components/toolbar-plugin.tsx index 5dc71e1e634d..15a049e1be0d 100644 --- a/apps/web/modules/ui/components/editor/components/toolbar-plugin.tsx +++ b/apps/web/modules/ui/components/editor/components/toolbar-plugin.tsx @@ -260,6 +260,7 @@ export const ToolbarPlugin = ( } }); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally reloads editor content only when updateTemplate/firstRender toggle; depending on editor/props would re-run on every unrelated prop change }, [props.updateTemplate, props.firstRender]); useEffect(() => { @@ -275,6 +276,7 @@ export const ToolbarPlugin = ( root.append(...nodes); }); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- one-time firstRender initialization; adding editor/props would re-seed the editor on unrelated changes }, []); // Register text-saving update listener - always active for each editor instance diff --git a/apps/web/modules/ui/components/file-input/index.tsx b/apps/web/modules/ui/components/file-input/index.tsx index a61476a709a0..929e9903038d 100644 --- a/apps/web/modules/ui/components/file-input/index.tsx +++ b/apps/web/modules/ui/components/file-input/index.tsx @@ -223,6 +223,7 @@ export const FileInput = ({ onFileUpload([videoUrlTemp], "video"); } } + // eslint-disable-next-line react-hooks/exhaustive-deps -- runs only on tab (fileType) change to sync temp URLs; adding the url/temp deps would re-fire uploads and loop }, [fileType]); return ( diff --git a/apps/web/modules/ui/components/multi-select/index.tsx b/apps/web/modules/ui/components/multi-select/index.tsx index 4ab93c561c5f..34885f9d2dee 100644 --- a/apps/web/modules/ui/components/multi-select/index.tsx +++ b/apps/web/modules/ui/components/multi-select/index.tsx @@ -70,6 +70,7 @@ export function MultiSelect["value"][]>( setSelected(newSelected); } } + // eslint-disable-next-line react-hooks/exhaustive-deps -- syncs the `value` prop into local state; `selected` is only read for comparison, depending on it would re-run on every user selection }, [value, options]); // Sync user-initiated selected changes to parent via onChange (deferred to avoid render issues) @@ -124,7 +125,7 @@ export function MultiSelect["value"][]>( input.blur(); } }, - [onChange, disabled] + [disabled] ); const selectableOptions = React.useMemo(() => { diff --git a/apps/web/modules/ui/components/preview-survey/index.tsx b/apps/web/modules/ui/components/preview-survey/index.tsx index 75b7087871bd..61aa7ed5a89e 100644 --- a/apps/web/modules/ui/components/preview-survey/index.tsx +++ b/apps/web/modules/ui/components/preview-survey/index.tsx @@ -154,6 +154,7 @@ export const PreviewSurvey = ({ resetProgress(); surveyNameTemp = survey.name; } + // eslint-disable-next-line react-hooks/exhaustive-deps -- refresh preview only when `survey` changes; resetProgress is recreated each render, so depending on it would re-run every render }, [survey]); const resetProgress = () => { diff --git a/apps/web/modules/ui/components/survey/index.tsx b/apps/web/modules/ui/components/survey/index.tsx index 6ead369c6989..57b9a9067f73 100644 --- a/apps/web/modules/ui/components/survey/index.tsx +++ b/apps/web/modules/ui/components/survey/index.tsx @@ -84,6 +84,7 @@ export const SurveyInline = (props: Omit) = }; loadScript(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- one-time script load guarded by hasLoadedRef; depending on loadSurveyScript/renderInline would re-trigger the load }, [props]); useEffect(() => { diff --git a/docs/surveys/website-app-surveys/framework-guides.mdx b/docs/surveys/website-app-surveys/framework-guides.mdx index 078c5d978551..c519e9bc5cd0 100644 --- a/docs/surveys/website-app-surveys/framework-guides.mdx +++ b/docs/surveys/website-app-surveys/framework-guides.mdx @@ -622,6 +622,59 @@ To this: ![second validate](https://res.cloudinary.com/dwdb9tvii/image/upload/v1738122750/image_ymaenn.jpg) +## Content Security Policy + +If your app serves a strict `Content-Security-Policy` header, the browser can block the Formbricks widget before anything renders. Three directives are involved. + + + This applies to the JavaScript SDK only. Link surveys are served by Formbricks itself, and the mobile + SDKs render surveys in a WebView, so neither is affected by your app's CSP. + + +### Allowing scripts and API calls + +The SDK loads two scripts from your Formbricks instance: `formbricks.umd.cjs` (the SDK) and `surveys.umd.cjs` (the survey renderer, fetched the first time a survey is shown). It then talks to the client API on the same host. Both directives need to point at your `appUrl`: + +```text +script-src 'self' https://app.formbricks.com; +connect-src 'self' https://app.formbricks.com; +``` + +The SDK does not put a nonce on the script tags it creates, so allowlisting the host is what makes them load. `'strict-dynamic'` works instead of a host allowlist, but only if the script that loads the SDK carries a matching nonce or hash of its own — both bundles are inserted programmatically, so they inherit trust from that root. Without a trusted root, `'strict-dynamic'` blocks the SDK before it ever reaches `surveys.umd.cjs`. + +### Passing a nonce for survey styles + +Surveys are styled by two `