mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-09 08:09:46 -06:00
fix: optimize survey list performance with client-side filtering (#6812)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
|
||||
9
apps/web/modules/survey/list/lib/constants.ts
Normal file
9
apps/web/modules/survey/list/lib/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { TSurveyFilters } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const initialFilters: TSurveyFilters = {
|
||||
name: "",
|
||||
createdBy: [],
|
||||
status: [],
|
||||
type: [],
|
||||
sortBy: "relevance",
|
||||
};
|
||||
Reference in New Issue
Block a user