fix: optimize survey list performance with client-side filtering (#6812)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Matti Nannt
2025-11-19 07:36:07 +01:00
committed by GitHub
parent 13be7a8970
commit f6683d1165
6 changed files with 71 additions and 80 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import toast from "react-hot-toast";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
@@ -30,10 +30,6 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList, isReadOnly: boolea
}
}, [survey, isReadOnly]);
useEffect(() => {
refreshSingleUseId();
}, [refreshSingleUseId]);
return {
singleUseId: isReadOnly ? undefined : singleUseId,
refreshSingleUseId: isReadOnly ? async () => undefined : refreshSingleUseId,

View File

@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { convertDateString, timeSince } from "@/lib/time";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { SurveyTypeIndicator } from "@/modules/survey/list/components/survey-type-indicator";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
@@ -48,8 +47,6 @@ export const SurveyCard = ({
const isSurveyCreationDeletionDisabled = isReadOnly;
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const linkHref = useMemo(() => {
return survey.status === "draft"
? `/environments/${environmentId}/surveys/${survey.id}/edit`
@@ -101,7 +98,6 @@ export const SurveyCard = ({
environmentId={environmentId}
publicDomain={publicDomain}
disabled={isDraftAndReadOnly}
refreshSingleUseId={refreshSingleUseId}
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
deleteSurvey={deleteSurvey}
onSurveysCopied={onSurveysCopied}

View File

@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
@@ -39,7 +39,6 @@ interface SurveyDropDownMenuProps {
environmentId: string;
survey: TSurvey;
publicDomain: string;
refreshSingleUseId: () => Promise<string | undefined>;
disabled?: boolean;
isSurveyCreationDeletionDisabled?: boolean;
deleteSurvey: (surveyId: string) => void;
@@ -50,7 +49,6 @@ export const SurveyDropDownMenu = ({
environmentId,
survey,
publicDomain,
refreshSingleUseId,
disabled,
isSurveyCreationDeletionDisabled,
deleteSurvey,
@@ -62,26 +60,11 @@ export const SurveyDropDownMenu = ({
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const [newSingleUseId, setNewSingleUseId] = useState<string | undefined>(undefined);
const router = useRouter();
const surveyLink = useMemo(() => publicDomain + "/s/" + survey.id, [survey.id, publicDomain]);
// Pre-fetch single-use ID when dropdown opens to avoid async delay during clipboard operation
// This ensures Safari's clipboard API works by maintaining the user gesture context
useEffect(() => {
if (!isDropDownOpen) return;
const fetchNewId = async () => {
try {
const newId = await refreshSingleUseId();
setNewSingleUseId(newId ?? undefined);
} catch (error) {
logger.error(error);
}
};
fetchNewId();
}, [refreshSingleUseId, isDropDownOpen]);
const isSingleUseEnabled = survey.singleUse?.enabled ?? false;
const handleDeleteSurvey = async (surveyId: string) => {
setLoading(true);
@@ -100,7 +83,8 @@ export const SurveyDropDownMenu = ({
try {
e.preventDefault();
setIsDropDownOpen(false);
const copiedLink = copySurveyLink(surveyLink, newSingleUseId);
// For single-use surveys, this button is disabled, so we just copy the base link
const copiedLink = copySurveyLink(surveyLink);
navigator.clipboard.writeText(copiedLink);
toast.success(t("common.copied_to_clipboard"));
} catch (error) {
@@ -205,31 +189,36 @@ export const SurveyDropDownMenu = ({
<>
<DropdownMenuItem>
<button
className="flex w-full cursor-pointer items-center"
type="button"
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
const newId = await refreshSingleUseId();
const previewUrl =
surveyLink + (newId ? `?suId=${newId}&preview=true` : "?preview=true");
const previewUrl = surveyLink + "?preview=true";
window.open(previewUrl, "_blank");
}}>
<EyeIcon className="mr-2 h-4 w-4" />
{t("common.preview_survey")}
</button>
</DropdownMenuItem>
{!survey.singleUse?.enabled && (
<DropdownMenuItem>
<button
type="button"
data-testid="copy-link"
className="flex w-full items-center"
onClick={async (e) => handleCopyLink(e)}>
<LinkIcon className="mr-2 h-4 w-4" />
{t("common.copy_link")}
</button>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<button
type="button"
data-testid="copy-link"
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
onClick={async (e) => handleCopyLink(e)}>
<LinkIcon className="mr-2 h-4 w-4" />
{t("common.copy_link")}
</button>
</DropdownMenuItem>
</>
)}
{!isSurveyCreationDeletionDisabled && (

View File

@@ -7,8 +7,9 @@ import { useTranslation } from "react-i18next";
import { useDebounce } from "react-use";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TFilterOption, TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { SortOption } from "@/modules/survey/list/components/sort-option";
import { initialFilters } from "@/modules/survey/list/components/survey-list";
import { initialFilters } from "@/modules/survey/list/lib/constants";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
@@ -154,12 +155,13 @@ export const SurveyFilters = ({
</div>
)}
{(createdBy.length > 0 || status.length > 0 || type.length > 0) && (
{(createdBy.length > 0 || status.length > 0 || type.length > 0 || name) && (
<Button
size="sm"
onClick={() => {
setSurveyFilters(initialFilters);
localStorage.removeItem("surveyFilters");
setName(""); // Also clear the search input
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}}
className="h-8">
{t("common.clear_filters")}

View File

@@ -10,6 +10,7 @@ import { TSurveyFilters } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { getSurveysAction } from "@/modules/survey/list/actions";
import { initialFilters } from "@/modules/survey/list/lib/constants";
import { getFormattedFilters } from "@/modules/survey/list/lib/utils";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { Button } from "@/modules/ui/components/button";
@@ -27,14 +28,6 @@ interface SurveysListProps {
locale: TUserLocale;
}
export const initialFilters: TSurveyFilters = {
name: "",
createdBy: [],
status: [],
type: [],
sortBy: "relevance",
};
export const SurveysList = ({
environmentId,
isReadOnly,
@@ -46,14 +39,18 @@ export const SurveysList = ({
}: SurveysListProps) => {
const router = useRouter();
const [surveys, setSurveys] = useState<TSurvey[]>([]);
const [isFetching, setIsFetching] = useState(true);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isFetching, setIsFetching] = useState(false);
const [hasMore, setHasMore] = useState<boolean>(false);
const [refreshTrigger, setRefreshTrigger] = useState(false);
const { t } = useTranslation();
const [surveyFilters, setSurveyFilters] = useState<TSurveyFilters>(initialFilters);
const [isFilterInitialized, setIsFilterInitialized] = useState(false);
const filters = useMemo(() => getFormattedFilters(surveyFilters, userId), [surveyFilters, userId]);
const { name, createdBy, status, type, sortBy } = surveyFilters;
const filters = useMemo(
() => getFormattedFilters(surveyFilters, userId),
[name, JSON.stringify(createdBy), JSON.stringify(status), JSON.stringify(type), sortBy, userId]
);
const [parent] = useAutoAnimate();
useEffect(() => {
@@ -80,28 +77,30 @@ export const SurveysList = ({
}, [surveyFilters, isFilterInitialized]);
useEffect(() => {
if (isFilterInitialized) {
const fetchInitialSurveys = async () => {
setIsFetching(true);
const res = await getSurveysAction({
environmentId,
limit: surveysLimit,
offset: undefined,
filterCriteria: filters,
});
if (res?.data) {
if (res.data.length < surveysLimit) {
setHasMore(false);
} else {
setHasMore(true);
}
setSurveys(res.data);
setIsFetching(false);
// Wait for filters to be loaded from localStorage before fetching
if (!isFilterInitialized) return;
const fetchFilteredSurveys = async () => {
setIsFetching(true);
const res = await getSurveysAction({
environmentId,
limit: surveysLimit,
offset: undefined,
filterCriteria: filters,
});
if (res?.data) {
if (res.data.length < surveysLimit) {
setHasMore(false);
} else {
setHasMore(true);
}
};
fetchInitialSurveys();
}
}, [environmentId, surveysLimit, filters, isFilterInitialized, refreshTrigger]);
setSurveys(res.data);
setIsFetching(false);
}
};
fetchFilteredSurveys();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [environmentId, surveysLimit, filters, refreshTrigger, isFilterInitialized]);
const fetchNextPage = useCallback(async () => {
setIsFetching(true);

View File

@@ -0,0 +1,9 @@
import { TSurveyFilters } from "@formbricks/types/surveys/types";
export const initialFilters: TSurveyFilters = {
name: "",
createdBy: [],
status: [],
type: [],
sortBy: "relevance",
};