feat: Relevance option for sorting (#2968)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
This commit is contained in:
Dhruwang Jariwala
2024-09-03 16:13:55 +05:30
committed by GitHub
parent b10d398728
commit 4003d21826
10 changed files with 217 additions and 54 deletions

View File

@@ -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 {

View File

@@ -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}/`);
};

View File

@@ -51,5 +51,6 @@ export const updateResponse = async (
};
export const formbricksLogout = async () => {
localStorage.clear();
return await formbricks.logout();
};

View File

@@ -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");

View File

@@ -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";

View File

@@ -277,6 +277,74 @@ export const getSurveysByActionClassId = reactCache(
)()
);
export const getSurveysSortedByRelevance = reactCache(
(
environmentId: string,
limit?: number,
offset?: number,
filterCriteria?: TSurveyFilterCriteria
): Promise<TSurvey[]> =>
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<number> =>
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<TSurvey> => {
validateInputs([updatedSurvey, ZSurvey]);
try {

View File

@@ -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 => {

View File

@@ -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<typeof ZSurveyFilterCriteria>;
@@ -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<typeof ZSurveyFilters>;
@@ -1484,7 +1484,7 @@ export type TFilterOption = z.infer<typeof ZFilterOption>;
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<typeof ZSortOption>;

View File

@@ -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">
<div
className={`flex h-8 w-8 items-center justify-center rounded-lg border p-1 ${orientation === "list" ? "bg-slate-900 text-white" : "bg-white"}`}
className={`flex h-8 w-8 items-center justify-center rounded-lg border p-1 ${
orientation === "list" ? "bg-slate-900 text-white" : "bg-white"
}`}
onClick={() => handleOrientationChange("list")}>
<Equal className="h-5 w-5" />
</div>
@@ -208,7 +215,9 @@ export const SurveyFilters = ({
tooltipContent={getToolTipContent("Grid")}
className="bg-slate-900 text-white">
<div
className={`flex h-8 w-8 items-center justify-center rounded-lg border p-1 ${orientation === "grid" ? "bg-slate-900 text-white" : "bg-white"}`}
className={`flex h-8 w-8 items-center justify-center rounded-lg border p-1 ${
orientation === "grid" ? "bg-slate-900 text-white" : "bg-white"
}`}
onClick={() => handleOrientationChange("grid")}>
<Grid2X2 className="h-5 w-5" />
</div>

View File

@@ -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<boolean>(true);
const [surveyFilters, setSurveyFilters] = useState<TSurveyFilters>(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 () => {