Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ export const MainNavigation = ({
}
});
},
[router, organization.id, workspace.id]
[router, organization.id]
);

const switcherTriggerClasses = cn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ElementOption>;
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,24 @@ export const ResponsePage = ({
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(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 () => {
Expand Down Expand Up @@ -107,7 +119,7 @@ export const ResponsePage = ({
} finally {
setIsFetchingFirstPage(false);
}
}, [filters, responsesPerPage, surveyId]);
}, [filters, responsesPerPage, surveyId, t]);

useEffect(() => {
return registerAnalysisRefreshHandler(refetchResponses);
Expand Down Expand Up @@ -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 (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,13 @@ export const ShareSurveyModal = ({
user.locale,
surveyUrl,
isReadOnly,
survey.workspaceId,
segments,
isContactsEnabled,
isFormbricksCloud,
email,
isStorageConfigured,
workspaceCustomScripts,
enterpriseLicenseRequestFormUrl,
]);

const getDefaultActiveId = useCallback(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand All @@ -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>(DateSelected.FROM);
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
Expand All @@ -158,7 +111,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),

[selectedFilter, dateRange]
[survey, selectedFilter, dateRange]
);

const datePickerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -294,81 +247,16 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
setDateRange({
from: startOfMonth(subMonths(new Date(), 1)),
to: endOfMonth(subMonths(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
setDateRange({
from: startOfQuarter(subQuarters(new Date(), 1)),
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
setDateRange({
from: startOfMonth(subMonths(new Date(), 6)),
to: endOfMonth(getTodayDate()),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
setDateRange({
from: startOfYear(subYears(new Date(), 1)),
to: endOfYear(subYears(getTodayDate(), 1)),
});
}}>
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
</DropdownMenuItem>
{DATE_RANGE_PRESETS.map(({ preset, getLabel }) => (
<DropdownMenuItem
key={preset}
onClick={() => {
setFilterRange(getLabel(t));
setDateRange({ ...resolveDateRangePresetBounds(preset), preset });
}}>
<p className="text-slate-700">{getLabel(t)}</p>
</DropdownMenuItem>
))}
<DropdownMenuItem
onClick={() => {
setIsDatePickerOpen(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
Loading
Loading