diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx index bf505234b9..b9b2788d4b 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings.tsx @@ -8,6 +8,7 @@ import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; +import { FORMBRICKS_PRODUCT_ID_LS, FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage"; import { PREVIEW_SURVEY } from "@formbricks/lib/styling/constants"; import { TProductConfigChannel, @@ -59,6 +60,14 @@ export const ProductSettings = ({ const productionEnvironment = createProductResponse.data.environments.find( (environment) => environment.type === "production" ); + if (productionEnvironment) { + if (typeof window !== "undefined") { + localStorage.setItem(FORMBRICKS_PRODUCT_ID_LS, productionEnvironment.productId); + + // Rmove filters when creating a new product + localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS); + } + } if (channel !== "link") { router.push(`/environments/${productionEnvironment?.id}/connect`); } else { diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx index ae59306481..90136ea077 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx @@ -30,6 +30,7 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { cn } from "@formbricks/lib/cn"; +import { FORMBRICKS_PRODUCT_ID_LS, FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings"; import { TEnvironment } from "@formbricks/types/environment"; @@ -114,6 +115,16 @@ export const MainNavigation = ({ } }, [organization]); + useEffect(() => { + if (typeof window === "undefined") return; + + const productId = localStorage.getItem(FORMBRICKS_PRODUCT_ID_LS); + const targetProduct = products.find((product) => product.id === productId); + if (targetProduct && productId && product && product.id !== targetProduct.id) { + router.push(`/products/${targetProduct.id}/`); + } + }, []); + const sortedOrganizations = useMemo(() => { return [...organizations].sort((a, b) => a.name.localeCompare(b.name)); }, [organizations]); @@ -140,6 +151,12 @@ export const MainNavigation = ({ }, [products]); const handleEnvironmentChangeByProduct = (productId: string) => { + if (typeof window !== "undefined") { + localStorage.setItem(FORMBRICKS_PRODUCT_ID_LS, productId); + + // Remove filters when switching products + localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS); + } router.push(`/products/${productId}/`); }; diff --git a/apps/web/app/lib/formbricks.ts b/apps/web/app/lib/formbricks.ts index 5dd6d1f21d..00015ed17c 100644 --- a/apps/web/app/lib/formbricks.ts +++ b/apps/web/app/lib/formbricks.ts @@ -51,5 +51,6 @@ export const updateResponse = async ( }; export const formbricksLogout = async () => { + localStorage.clear(); return await formbricks.logout(); }; diff --git a/apps/web/playwright/js.spec.ts b/apps/web/playwright/js.spec.ts index e1ff23b2a6..2188278d99 100644 --- a/apps/web/playwright/js.spec.ts +++ b/apps/web/playwright/js.spec.ts @@ -105,6 +105,7 @@ test.describe("JS Package Test", async () => { (await page.waitForSelector("text=Responses")).isVisible(); await page.waitForLoadState("networkidle"); + await page.waitForTimeout(2000); const impressionsCount = await page.getByRole("button", { name: "Impressions" }).innerText(); expect(impressionsCount).toEqual("Impressions\n\n1"); diff --git a/packages/lib/localStorage.ts b/packages/lib/localStorage.ts new file mode 100644 index 0000000000..fa43d85596 --- /dev/null +++ b/packages/lib/localStorage.ts @@ -0,0 +1,3 @@ +export const FORMBRICKS_SURVEYS_ORIENTATION_KEY_LS = "formbricks-surveys-orientation"; +export const FORMBRICKS_SURVEYS_FILTERS_KEY_LS = "formbricks-surveys-filters"; +export const FORMBRICKS_PRODUCT_ID_LS = "formbricks-product-id"; diff --git a/packages/lib/survey/service.ts b/packages/lib/survey/service.ts index 7876604a22..7ba868dcd5 100644 --- a/packages/lib/survey/service.ts +++ b/packages/lib/survey/service.ts @@ -277,6 +277,74 @@ export const getSurveysByActionClassId = reactCache( )() ); +export const getSurveysSortedByRelevance = reactCache( + ( + environmentId: string, + limit?: number, + offset?: number, + filterCriteria?: TSurveyFilterCriteria + ): Promise => + cache( + async () => { + validateInputs([environmentId, ZId], [limit, ZOptionalNumber], [offset, ZOptionalNumber]); + + try { + let surveys: TSurvey[] = []; + const inProgressSurveyCount = await getInProgressSurveyCount(environmentId, filterCriteria); + + // Fetch surveys that are in progress first + const inProgressSurveys = + offset && offset > inProgressSurveyCount + ? [] + : await prisma.survey.findMany({ + where: { + environmentId, + status: "inProgress", + ...buildWhereClause(filterCriteria), + }, + select: selectSurvey, + orderBy: buildOrderByClause("updatedAt"), + take: limit, + skip: offset, + }); + + surveys = inProgressSurveys.map(transformPrismaSurvey); + + // Determine if additional surveys are needed + if (offset !== undefined && limit && inProgressSurveys.length < limit) { + const remainingLimit = limit - inProgressSurveys.length; + const newOffset = Math.max(0, offset - inProgressSurveyCount); + const additionalSurveys = await prisma.survey.findMany({ + where: { + environmentId, + status: { not: "inProgress" }, + ...buildWhereClause(filterCriteria), + }, + select: selectSurvey, + orderBy: buildOrderByClause("updatedAt"), + take: remainingLimit, + skip: newOffset, + }); + + surveys = [...surveys, ...additionalSurveys.map(transformPrismaSurvey)]; + } + + return surveys; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } + }, + [`getSurveysSortedByRelevance-${environmentId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`], + { + tags: [surveyCache.tag.byEnvironmentId(environmentId)], + } + )() +); + export const getSurveys = reactCache( ( environmentId: string, @@ -287,35 +355,33 @@ export const getSurveys = reactCache( cache( async () => { validateInputs([environmentId, ZId], [limit, ZOptionalNumber], [offset, ZOptionalNumber]); - let surveysPrisma; try { - surveysPrisma = await prisma.survey.findMany({ + if (filterCriteria?.sortBy === "relevance") { + // Call the sortByRelevance function + return await getSurveysSortedByRelevance(environmentId, limit, offset ?? 0, filterCriteria); + } + + // Fetch surveys normally with pagination + const surveysPrisma = await prisma.survey.findMany({ where: { environmentId, ...buildWhereClause(filterCriteria), }, select: selectSurvey, orderBy: buildOrderByClause(filterCriteria?.sortBy), - take: limit ? limit : undefined, - skip: offset ? offset : undefined, + take: limit, + skip: offset, }); + + return surveysPrisma.map(transformPrismaSurvey); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { console.error(error); throw new DatabaseError(error.message); } - throw error; } - - const surveys: TSurvey[] = []; - - for (const surveyPrisma of surveysPrisma) { - const transformedSurvey = transformPrismaSurvey(surveyPrisma); - surveys.push(transformedSurvey); - } - return surveys; }, [`getSurveys-${environmentId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`], { @@ -353,6 +419,37 @@ export const getSurveyCount = reactCache( )() ); +export const getInProgressSurveyCount = reactCache( + (environmentId: string, filterCriteria?: TSurveyFilterCriteria): Promise => + cache( + async () => { + validateInputs([environmentId, ZId]); + try { + const surveyCount = await prisma.survey.count({ + where: { + environmentId: environmentId, + status: "inProgress", + ...buildWhereClause(filterCriteria), + }, + }); + + return surveyCount; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getInProgressSurveyCount-${environmentId}-${JSON.stringify(filterCriteria)}`], + { + tags: [surveyCache.tag.byEnvironmentId(environmentId)], + } + )() +); + export const updateSurvey = async (updatedSurvey: TSurvey): Promise => { validateInputs([updatedSurvey, ZSurvey]); try { diff --git a/packages/lib/survey/utils.ts b/packages/lib/survey/utils.ts index 27a5815f57..6ffa786a99 100644 --- a/packages/lib/survey/utils.ts +++ b/packages/lib/survey/utils.ts @@ -70,15 +70,13 @@ export const buildWhereClause = (filterCriteria?: TSurveyFilterCriteria) => { export const buildOrderByClause = ( sortBy?: TSurveyFilterCriteria["sortBy"] ): Prisma.SurveyOrderByWithRelationInput[] | undefined => { - if (!sortBy) { - return undefined; - } + const orderMapping: { [key: string]: Prisma.SurveyOrderByWithRelationInput } = { + name: { name: "asc" }, + createdAt: { createdAt: "desc" }, + updatedAt: { updatedAt: "desc" }, + }; - if (sortBy === "name") { - return [{ name: "asc" }]; - } - - return [{ [sortBy]: "desc" }]; + return sortBy ? [orderMapping[sortBy] || { updatedAt: "desc" }] : undefined; }; export const anySurveyHasFilters = (surveys: TSurvey[]): boolean => { diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index b9abee5a6f..59c7338547 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -1460,7 +1460,7 @@ export const ZSurveyFilterCriteria = z.object({ value: z.array(z.enum(["you", "others"])), }) .optional(), - sortBy: z.enum(["createdAt", "updatedAt", "name"]).optional(), + sortBy: z.enum(["createdAt", "updatedAt", "name", "relevance"]).optional(), }); export type TSurveyFilterCriteria = z.infer; @@ -1470,7 +1470,7 @@ const ZSurveyFilters = z.object({ createdBy: z.array(z.enum(["you", "others"])), status: z.array(ZSurveyStatus), type: z.array(ZSurveyType), - sortBy: z.enum(["createdAt", "updatedAt", "name"]), + sortBy: z.enum(["createdAt", "updatedAt", "name", "relevance"]), }); export type TSurveyFilters = z.infer; @@ -1484,7 +1484,7 @@ export type TFilterOption = z.infer; const ZSortOption = z.object({ label: z.string(), - value: z.enum(["createdAt", "updatedAt", "name"]), + value: z.enum(["createdAt", "updatedAt", "name", "relevance"]), }); export type TSortOption = z.infer; diff --git a/packages/ui/SurveysList/components/SurveyFilters.tsx b/packages/ui/SurveysList/components/SurveyFilters.tsx index 34a6f7aeaa..34b329dade 100644 --- a/packages/ui/SurveysList/components/SurveyFilters.tsx +++ b/packages/ui/SurveysList/components/SurveyFilters.tsx @@ -1,6 +1,7 @@ import { ChevronDownIcon, Equal, Grid2X2, Search, X } from "lucide-react"; import { useState } from "react"; import { useDebounce } from "react-use"; +import { FORMBRICKS_SURVEYS_ORIENTATION_KEY_LS } from "@formbricks/lib/localStorage"; import { TProductConfigChannel } from "@formbricks/types/product"; import { TFilterOption, TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types"; import { initialFilters } from ".."; @@ -44,7 +45,10 @@ const sortOptions: TSortOption[] = [ label: "Alphabetical", value: "name", }, - // Add other sorting options as needed + { + label: "Relevance", + value: "relevance", + }, ]; const getToolTipContent = (orientation: string) => { @@ -125,7 +129,7 @@ export const SurveyFilters = ({ const handleOrientationChange = (value: string) => { setOrientation(value); - localStorage.setItem("surveyOrientation", value); + localStorage.setItem(FORMBRICKS_SURVEYS_ORIENTATION_KEY_LS, value); }; return ( @@ -183,6 +187,7 @@ export const SurveyFilters = ({ size="sm" onClick={() => { setSurveyFilters(initialFilters); + localStorage.removeItem("surveyFilters"); }} className="h-8" EndIcon={X} @@ -197,7 +202,9 @@ export const SurveyFilters = ({ tooltipContent={getToolTipContent("List")} className="bg-slate-900 text-white">
handleOrientationChange("list")}>
@@ -208,7 +215,9 @@ export const SurveyFilters = ({ tooltipContent={getToolTipContent("Grid")} className="bg-slate-900 text-white">
handleOrientationChange("grid")}>
diff --git a/packages/ui/SurveysList/index.tsx b/packages/ui/SurveysList/index.tsx index c780e50d5c..5c597bf35a 100644 --- a/packages/ui/SurveysList/index.tsx +++ b/packages/ui/SurveysList/index.tsx @@ -1,7 +1,12 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { + FORMBRICKS_SURVEYS_FILTERS_KEY_LS, + FORMBRICKS_SURVEYS_ORIENTATION_KEY_LS, +} from "@formbricks/lib/localStorage"; import { TEnvironment } from "@formbricks/types/environment"; +import { wrapThrows } from "@formbricks/types/error-handlers"; import { TProductConfigChannel } from "@formbricks/types/product"; import { TSurvey, TSurveyFilters } from "@formbricks/types/surveys/types"; import { Button } from "../Button"; @@ -26,7 +31,7 @@ export const initialFilters: TSurveyFilters = { createdBy: [], status: [], type: [], - sortBy: "updatedAt", + sortBy: "relevance", }; export const SurveysList = ({ @@ -43,42 +48,65 @@ export const SurveysList = ({ const [hasMore, setHasMore] = useState(true); const [surveyFilters, setSurveyFilters] = useState(initialFilters); + const [isFilterInitialized, setIsFilterInitialized] = useState(false); const filters = useMemo(() => getFormattedFilters(surveyFilters, userId), [surveyFilters, userId]); const [orientation, setOrientation] = useState(""); useEffect(() => { - // Initialize orientation state with a function that checks if window is defined - const orientationFromLocalStorage = localStorage.getItem("surveyOrientation"); - if (orientationFromLocalStorage) { - setOrientation(orientationFromLocalStorage); - } else { - setOrientation("grid"); - localStorage.setItem("surveyOrientation", "grid"); + if (typeof window !== "undefined") { + const orientationFromLocalStorage = localStorage.getItem(FORMBRICKS_SURVEYS_ORIENTATION_KEY_LS); + if (orientationFromLocalStorage) { + setOrientation(orientationFromLocalStorage); + } else { + setOrientation("grid"); + localStorage.setItem(FORMBRICKS_SURVEYS_ORIENTATION_KEY_LS, "grid"); + } + + const savedFilters = localStorage.getItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS); + if (savedFilters) { + const surveyParseResult = wrapThrows(() => JSON.parse(savedFilters))(); + + if (!surveyParseResult.ok) { + localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS); + setSurveyFilters(initialFilters); + } else { + setSurveyFilters(surveyParseResult.data); + } + } + setIsFilterInitialized(true); } }, []); useEffect(() => { - const fetchInitialSurveys = async () => { - setIsFetching(true); - const res = await getSurveysAction({ - environmentId: environment.id, - limit: surveysLimit, - offset: undefined, - filterCriteria: filters, - }); - if (res?.data) { - if (res.data.length < surveysLimit) { - setHasMore(false); - } else { - setHasMore(true); + if (isFilterInitialized) { + localStorage.setItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS, JSON.stringify(surveyFilters)); + } + }, [surveyFilters, isFilterInitialized]); + + useEffect(() => { + if (isFilterInitialized) { + const fetchInitialSurveys = async () => { + setIsFetching(true); + const res = await getSurveysAction({ + environmentId: environment.id, + limit: surveysLimit, + offset: undefined, + filterCriteria: filters, + }); + if (res?.data) { + if (res.data.length < surveysLimit) { + setHasMore(false); + } else { + setHasMore(true); + } + setSurveys(res.data); + setIsFetching(false); } - setSurveys(res.data); - setIsFetching(false); - } - }; - fetchInitialSurveys(); + }; + fetchInitialSurveys(); + } }, [environment.id, surveysLimit, filters]); const fetchNextPage = useCallback(async () => {