feat: Add Server-side Filtering to the Surveys Page (#2277)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
This commit is contained in:
Piyush Gupta
2024-04-11 09:24:15 +05:30
committed by GitHub
parent bfb6012048
commit 4100949bf6
14 changed files with 394 additions and 238 deletions

View File

@@ -13,7 +13,7 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurveyCount } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import ContentWrapper from "@formbricks/ui/ContentWrapper";
import SurveysList from "@formbricks/ui/SurveysList";
import { SurveysList } from "@formbricks/ui/SurveysList";
export const metadata: Metadata = {
title: "Your Surveys",

View File

@@ -79,7 +79,7 @@ export const MAIL_FROM = env.MAIL_FROM;
export const NEXTAUTH_SECRET = env.NEXTAUTH_SECRET;
export const ITEMS_PER_PAGE = 50;
export const SURVEYS_PER_PAGE = 20;
export const SURVEYS_PER_PAGE = 12;
export const RESPONSES_PER_PAGE = 10;
export const TEXT_RESPONSES_PER_PAGE = 5;

View File

@@ -37,7 +37,7 @@ export function calculateTtcTotal(ttc: TResponseTtc) {
}
export const buildWhereClause = (filterCriteria?: TResponseFilterCriteria) => {
const whereClause: Record<string, any>[] = [];
const whereClause: Prisma.ResponseWhereInput["AND"] = [];
// For finished
if (filterCriteria?.finished !== undefined) {

View File

@@ -11,7 +11,12 @@ import { ZId } from "@formbricks/types/environment";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TPerson } from "@formbricks/types/people";
import { TSegment, ZSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey, TSurveyInput, ZSurveyWithRefinements } from "@formbricks/types/surveys";
import {
TSurvey,
TSurveyFilterCriteria,
TSurveyInput,
ZSurveyWithRefinements,
} from "@formbricks/types/surveys";
import { getActionsByPersonId } from "../action/service";
import { getActionClasses } from "../actionClass/service";
@@ -31,7 +36,7 @@ import { subscribeTeamMembersToSurveyResponses } from "../team/service";
import { diffInDays, formatDateFields } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { surveyCache } from "./cache";
import { anySurveyHasFilters, formatSurveyDateFields } from "./util";
import { anySurveyHasFilters, buildOrderByClause, buildWhereClause, formatSurveyDateFields } from "./util";
interface TriggerUpdate {
create?: Array<{ actionClassId: string }>;
@@ -283,7 +288,8 @@ export const getSurveysByActionClassId = async (actionClassId: string, page?: nu
export const getSurveys = async (
environmentId: string,
limit?: number,
offset?: number
offset?: number,
filterCriteria?: TSurveyFilterCriteria
): Promise<TSurvey[]> => {
const surveys = await unstable_cache(
async () => {
@@ -294,13 +300,10 @@ export const getSurveys = async (
surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
...buildWhereClause(filterCriteria),
},
select: selectSurvey,
orderBy: [
{
updatedAt: "desc",
},
],
orderBy: buildOrderByClause(filterCriteria?.sortBy),
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
@@ -335,7 +338,7 @@ export const getSurveys = async (
}
return surveys;
},
[`getSurveys-${environmentId}-${limit}-${offset}`],
[`getSurveys-${environmentId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,

View File

@@ -1,8 +1,10 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { TLegacySurvey } from "@formbricks/types/LegacySurvey";
import { TPerson } from "@formbricks/types/people";
import { TSurvey } from "@formbricks/types/surveys";
import { TSurvey, TSurveyFilterCriteria } from "@formbricks/types/surveys";
export const formatSurveyDateFields = (survey: TSurvey): TSurvey => {
if (typeof survey.createdAt === "string") {
@@ -42,6 +44,53 @@ export const formatSurveyDateFields = (survey: TSurvey): TSurvey => {
return survey;
};
export const buildWhereClause = (filterCriteria?: TSurveyFilterCriteria) => {
const whereClause: Prisma.SurveyWhereInput["AND"] = [];
// for name
if (filterCriteria?.name) {
whereClause.push({ name: { contains: filterCriteria.name, mode: "insensitive" } });
}
// for status
if (filterCriteria?.status && filterCriteria?.status?.length) {
whereClause.push({ status: { in: filterCriteria.status } });
}
// for type
if (filterCriteria?.type && filterCriteria?.type?.length) {
whereClause.push({ type: { in: filterCriteria.type } });
}
// for createdBy
if (filterCriteria?.createdBy?.value && filterCriteria?.createdBy?.value?.length) {
if (filterCriteria.createdBy.value.length === 1) {
if (filterCriteria.createdBy.value[0] === "you") {
whereClause.push({ createdBy: filterCriteria.createdBy.userId });
}
if (filterCriteria.createdBy.value[0] === "others") {
whereClause.push({ createdBy: { not: filterCriteria.createdBy.userId } });
}
}
}
return { AND: whereClause };
};
export const buildOrderByClause = (
sortBy?: TSurveyFilterCriteria["sortBy"]
): Prisma.SurveyOrderByWithRelationInput[] | undefined => {
if (!sortBy) {
return undefined;
}
if (sortBy === "name") {
return [{ name: "asc" }];
}
return [{ [sortBy]: "desc" }];
};
export const anySurveyHasFilters = (surveys: TSurvey[] | TLegacySurvey[]): boolean => {
return surveys.some((survey) => {
if ("segment" in survey && survey.segment) {

View File

@@ -580,4 +580,43 @@ export interface TSurveyQuestionSummary<T> {
}[];
}
export const ZSurveyFilterCriteria = z.object({
name: z.string().optional(),
status: z.array(ZSurveyStatus).optional(),
type: z.array(ZSurveyType).optional(),
createdBy: z
.object({
userId: z.string(),
value: z.array(z.enum(["you", "others"])),
})
.optional(),
sortBy: z.enum(["createdAt", "updatedAt", "name"]).optional(),
});
export type TSurveyFilterCriteria = z.infer<typeof ZSurveyFilterCriteria>;
const ZSurveyFilters = z.object({
name: z.string(),
createdBy: z.array(z.enum(["you", "others"])),
status: z.array(ZSurveyStatus),
type: z.array(ZSurveyType),
sortBy: z.enum(["createdAt", "updatedAt", "name"]),
});
export type TSurveyFilters = z.infer<typeof ZSurveyFilters>;
export type TSurveyEditorTabs = "questions" | "settings" | "styling";
const ZFilterOption = z.object({
label: z.string(),
value: z.string(),
});
export type TFilterOption = z.infer<typeof ZFilterOption>;
const ZSortOption = z.object({
label: z.string(),
value: z.enum(["createdAt", "updatedAt", "name"]),
});
export type TSortOption = z.infer<typeof ZSortOption>;

View File

@@ -11,6 +11,7 @@ import { surveyCache } from "@formbricks/lib/survey/cache";
import { deleteSurvey, duplicateSurvey, getSurvey, getSurveys } from "@formbricks/lib/survey/service";
import { generateSurveySingleUseId } from "@formbricks/lib/utils/singleUseSurveys";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyFilterCriteria } from "@formbricks/types/surveys";
export const getSurveyAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
@@ -22,7 +23,7 @@ export const getSurveyAction = async (surveyId: string) => {
return await getSurvey(surveyId);
};
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
export const duplicateSurveyAction = async (environmentId: string, surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
@@ -31,13 +32,13 @@ export async function duplicateSurveyAction(environmentId: string, surveyId: str
const duplicatedSurvey = await duplicateSurvey(environmentId, surveyId, session.user.id);
return duplicatedSurvey;
}
};
export async function copyToOtherEnvironmentAction(
export const copyToOtherEnvironmentAction = async (
environmentId: string,
surveyId: string,
targetEnvironmentId: string
) {
) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
@@ -195,7 +196,7 @@ export async function copyToOtherEnvironmentAction(
environmentId: targetEnvironmentId,
});
return newSurvey;
}
};
export const deleteSurveyAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
@@ -212,7 +213,7 @@ export const deleteSurveyAction = async (surveyId: string) => {
await deleteSurvey(surveyId);
};
export async function generateSingleUseIdAction(surveyId: string, isEncrypted: boolean): Promise<string> {
export const generateSingleUseIdAction = async (surveyId: string, isEncrypted: boolean): Promise<string> => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
@@ -221,14 +222,19 @@ export async function generateSingleUseIdAction(surveyId: string, isEncrypted: b
if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized");
return generateSurveySingleUseId(isEncrypted);
}
};
export async function getSurveysAction(environmentId: string, limit?: number, offset?: number) {
export const getSurveysAction = async (
environmentId: string,
limit?: number,
offset?: number,
filterCriteria?: TSurveyFilterCriteria
) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await getSurveys(environmentId, limit, offset);
}
return await getSurveys(environmentId, limit, offset, filterCriteria);
};

View File

@@ -0,0 +1,24 @@
import { TSortOption, TSurveyFilters } from "@formbricks/types/surveys";
import { DropdownMenuItem } from "../../DropdownMenu";
interface SortOptionProps {
option: TSortOption;
sortBy: TSurveyFilters["sortBy"];
handleSortChange: (option: TSortOption) => void;
}
export const SortOption = ({ option, sortBy, handleSortChange }: SortOptionProps) => (
<DropdownMenuItem
key={option.label}
className="m-0 p-0"
onClick={() => {
handleSortChange(option);
}}>
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
<span
className={`h-4 w-4 rounded-full border ${sortBy === option.value ? "bg-brand-dark outline-brand-dark border-slate-900 outline" : "border-white"}`}></span>
<p className="font-normal text-white">{option.label}</p>
</div>
</DropdownMenuItem>
);

View File

@@ -9,7 +9,7 @@ import { TSurvey } from "@formbricks/types/surveys";
import { SurveyStatusIndicator } from "../../SurveyStatusIndicator";
import { generateSingleUseIdAction } from "../actions";
import SurveyDropDownMenu from "./SurveyDropdownMenu";
import { SurveyDropDownMenu } from "./SurveyDropdownMenu";
interface SurveyCardProps {
survey: TSurvey;
@@ -21,7 +21,7 @@ interface SurveyCardProps {
duplicateSurvey: (survey: TSurvey) => void;
deleteSurvey: (surveyId: string) => void;
}
export default function SurveyCard({
export const SurveyCard = ({
survey,
environment,
otherEnvironment,
@@ -30,7 +30,7 @@ export default function SurveyCard({
orientation,
deleteSurvey,
duplicateSurvey,
}: SurveyCardProps) {
}: SurveyCardProps) => {
const isSurveyCreationDeletionDisabled = isViewer;
const surveyStatusLabel = useMemo(() => {
@@ -119,7 +119,7 @@ export default function SurveyCard({
key={survey.id}
className="relative grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4
shadow-sm transition-all ease-in-out hover:scale-[101%]">
<div className="col-span-2 flex items-center justify-self-start overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-slate-900">
<div className="col-span-2 flex max-w-full items-center justify-self-start truncate whitespace-nowrap text-sm font-medium text-slate-900">
{survey.name}
</div>
<div
@@ -162,6 +162,8 @@ export default function SurveyCard({
</Link>
);
};
if (orientation === "grid") return renderGridContent();
else return renderListContent();
}
if (orientation === "grid") {
return renderGridContent();
} else return renderListContent();
};

View File

@@ -38,7 +38,7 @@ interface SurveyDropDownMenuProps {
deleteSurvey: (surveyId: string) => void;
}
export default function SurveyDropDownMenu({
export const SurveyDropDownMenu = ({
environmentId,
survey,
environment,
@@ -48,7 +48,7 @@ export default function SurveyDropDownMenu({
isSurveyCreationDeletionDisabled,
deleteSurvey,
duplicateSurvey,
}: SurveyDropDownMenuProps) {
}: SurveyDropDownMenuProps) => {
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
@@ -244,4 +244,4 @@ export default function SurveyDropDownMenu({
)}
</div>
);
}
};

View File

@@ -0,0 +1,59 @@
import { ChevronDownIcon } from "lucide-react";
import { TFilterOption } from "@formbricks/types/surveys";
import { Checkbox } from "../../Checkbox";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../DropdownMenu";
interface SurveyFilterDropdownProps {
title: string;
id: "createdBy" | "status" | "type";
options: TFilterOption[];
selectedOptions: string[];
setSelectedOptions: (value: string) => void;
isOpen: boolean;
toggleDropdown: (id: string) => void;
}
export const SurveyFilterDropdown = ({
title,
id,
options,
selectedOptions,
setSelectedOptions,
isOpen,
toggleDropdown,
}: SurveyFilterDropdownProps) => {
const triggerClasses = `surveyFilterDropdown min-w-auto h-8 rounded-md border border-slate-700 sm:px-2 cursor-pointer outline-none
${selectedOptions.length > 0 ? "bg-slate-900 text-white" : "hover:bg-slate-900"}`;
return (
<DropdownMenu open={isOpen} onOpenChange={() => toggleDropdown(id)}>
<DropdownMenuTrigger asChild className={triggerClasses}>
<div className="flex w-full items-center justify-between">
<span className="text-sm">{title}</span>
<ChevronDownIcon className="ml-2 h-4 w-4" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-slate-900">
{options.map((option) => (
<DropdownMenuItem
key={option.value}
className="m-0 p-0"
onClick={(e) => {
e.preventDefault();
setSelectedOptions(option.value);
}}>
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
<Checkbox
checked={selectedOptions.includes(option.value)}
className={`bg-white ${selectedOptions.includes(option.value) ? "bg-brand-dark border-none" : ""}`}
/>
<p className="font-normal text-white">{option.label}</p>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -1,76 +1,52 @@
import { ChevronDownIcon, Equal, Grid2X2, Search, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useDebounce } from "react-use";
import { TSurvey } from "@formbricks/types/surveys";
import { TFilterOption, TSortOption, TSurveyFilters } from "@formbricks/types/surveys";
import { initialFilters } from "..";
import { Button } from "../../Button";
import { Checkbox } from "../../Checkbox";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../DropdownMenu";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "../../DropdownMenu";
import { TooltipRenderer } from "../../Tooltip";
import { SortOption } from "./SortOption";
import { SurveyFilterDropdown } from "./SurveyFilterDropdown";
interface SurveyFilterProps {
surveys: TSurvey[];
setFilteredSurveys: (surveys: TSurvey[]) => void;
orientation: string;
setOrientation: (orientation: string) => void;
userId: string;
}
interface TFilterOption {
label: string;
value: string;
}
interface TSortOption {
label: string;
sortFunction: (a: TSurvey, b: TSurvey) => number;
surveyFilters: TSurveyFilters;
setSurveyFilters: React.Dispatch<React.SetStateAction<TSurveyFilters>>;
}
interface FilterDropdownProps {
title: string;
id: string;
options: TFilterOption[];
selectedOptions: string[];
setSelectedOptions: (options: string[]) => void;
isOpen: boolean;
}
const creatorOptions: TFilterOption[] = [
{ label: "You", value: "you" },
{ label: "Others", value: "others" },
];
const statusOptions = [
const statusOptions: TFilterOption[] = [
{ label: "In Progress", value: "inProgress" },
{ label: "Scheduled", value: "scheduled" },
{ label: "Paused", value: "paused" },
{ label: "Completed", value: "completed" },
{ label: "Draft", value: "draft" },
];
const typeOptions = [
const typeOptions: TFilterOption[] = [
{ label: "Link", value: "link" },
{ label: "In-app", value: "web" },
];
const sortOptions = [
const sortOptions: TSortOption[] = [
{
label: "Last Modified",
sortFunction: (a: TSurvey, b: TSurvey) => {
const dateA = new Date(a.updatedAt);
const dateB = new Date(b.updatedAt);
if (!isNaN(dateA.getTime()) && !isNaN(dateB.getTime())) {
return dateB.getTime() - dateA.getTime();
}
return 0;
},
value: "updatedAt",
},
{
label: "Created On",
sortFunction: (a: TSurvey, b: TSurvey) => {
const dateA = new Date(a.createdAt);
const dateB = new Date(b.createdAt);
if (!isNaN(dateA.getTime()) && !isNaN(dateB.getTime())) {
return dateB.getTime() - dateA.getTime();
}
return 0;
},
value: "createdAt",
},
{
label: "Alphabetical",
sortFunction: (a: TSurvey, b: TSurvey) => a.name.localeCompare(b.name),
value: "name",
},
// Add other sorting options as needed
];
@@ -79,129 +55,66 @@ const getToolTipContent = (orientation: string) => {
return <div>{orientation} View</div>;
};
export default function SurveyFilters({
surveys,
setFilteredSurveys,
export const SurveyFilters = ({
orientation,
setOrientation,
userId,
}: SurveyFilterProps) {
const [createdByFilter, setCreatedByFilter] = useState<string[]>([]);
const [statusFilters, setStatusFilters] = useState<string[]>([]);
const [typeFilters, setTypeFilters] = useState<string[]>([]);
const [sortBy, setSortBy] = useState(sortOptions[0]);
const [searchTerm, setSearchTerm] = useState("");
surveyFilters,
setSurveyFilters,
}: SurveyFilterProps) => {
const { createdBy, sortBy, status, type } = surveyFilters;
const [name, setName] = useState("");
useDebounce(() => setSurveyFilters((prev) => ({ ...prev, name: name })), 800, [name]);
const [dropdownOpenStates, setDropdownOpenStates] = useState(new Map());
const toggleDropdown = (id: string) => {
setDropdownOpenStates(new Map(dropdownOpenStates).set(id, !dropdownOpenStates.get(id)));
};
const creatorOptions = [
{ label: "You", value: userId },
{ label: "Others", value: "other" },
];
useEffect(() => {
let filtered = [...surveys];
// Filter by search term
if (searchTerm) {
filtered = filtered.filter((survey) => survey.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
if (createdByFilter.length > 0) {
filtered = filtered.filter((survey) => {
if (survey.createdBy) {
if (createdByFilter.length === 2) return true;
if (createdByFilter.includes("other")) return survey.createdBy !== userId;
else {
return survey.createdBy === userId;
}
}
});
}
if (statusFilters.length > 0) {
filtered = filtered.filter((survey) => statusFilters.includes(survey.status));
}
if (typeFilters.length > 0) {
filtered = filtered.filter((survey) => typeFilters.includes(survey.type));
}
if (sortBy && sortBy.sortFunction) {
filtered.sort(sortBy.sortFunction);
}
setFilteredSurveys(filtered);
}, [createdByFilter, statusFilters, typeFilters, sortBy, searchTerm, surveys]);
const handleFilterChange = (
value: string,
selectedOptions: string[],
setSelectedOptions: (options: string[]) => void
) => {
if (selectedOptions.includes(value)) {
setSelectedOptions(selectedOptions.filter((option) => option !== value));
} else {
setSelectedOptions([...selectedOptions, value]);
const handleCreatedByChange = (value: string) => {
if (value === "you" || value === "others") {
if (createdBy.includes(value)) {
setSurveyFilters((prev) => ({ ...prev, createdBy: prev.createdBy.filter((v) => v !== value) }));
} else {
setSurveyFilters((prev) => ({ ...prev, createdBy: [...prev.createdBy, value] }));
}
}
};
const renderSortOption = (option: TSortOption) => (
<DropdownMenuItem
key={option.label}
className="m-0 p-0"
onClick={() => {
setSortBy(option);
}}>
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
<span
className={`h-4 w-4 rounded-full border ${sortBy === option ? "bg-brand-dark outline-brand-dark border-slate-900 outline" : "border-white"}`}></span>
<p className="font-normal text-white">{option.label}</p>
</div>
</DropdownMenuItem>
);
const handleStatusChange = (value: string) => {
if (
value === "inProgress" ||
value === "paused" ||
value === "completed" ||
value === "draft" ||
value === "scheduled"
) {
if (status.includes(value)) {
setSurveyFilters((prev) => ({ ...prev, status: prev.status.filter((v) => v !== value) }));
} else {
setSurveyFilters((prev) => ({ ...prev, status: [...prev.status, value] }));
}
}
};
const FilterDropdown = ({
title,
id,
options,
selectedOptions,
setSelectedOptions,
isOpen,
}: FilterDropdownProps) => {
const triggerClasses = `surveyFilterDropdown min-w-auto h-8 rounded-md border border-slate-700 sm:px-2 cursor-pointer outline-none
${selectedOptions.length > 0 ? "bg-slate-900 text-white" : "hover:bg-slate-900"}`;
const handleTypeChange = (value: string) => {
if (value === "link" || value === "web") {
if (type.includes(value)) {
setSurveyFilters((prev) => ({ ...prev, type: prev.type.filter((v) => v !== value) }));
} else {
setSurveyFilters((prev) => ({ ...prev, type: [...prev.type, value] }));
}
}
};
return (
<DropdownMenu open={isOpen} onOpenChange={() => toggleDropdown(id)}>
<DropdownMenuTrigger asChild className={triggerClasses}>
<div className="flex w-full items-center justify-between">
<span className="text-sm">{title}</span>
<ChevronDownIcon className="ml-2 h-4 w-4" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-slate-900">
{options.map((option) => (
<DropdownMenuItem
key={option.value}
className="m-0 p-0"
onClick={(e) => {
e.preventDefault();
handleFilterChange(option.value, selectedOptions, setSelectedOptions);
}}>
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
<Checkbox
checked={selectedOptions.includes(option.value)}
className={`bg-white ${selectedOptions.includes(option.value) ? "bg-brand-dark border-none" : ""}`}
/>
<p className="font-normal text-white">{option.label}</p>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
const handleSortChange = (option: TSortOption) => {
setSurveyFilters((prev) => ({ ...prev, sortBy: option.value }));
};
const handleOrientationChange = (value: string) => {
setOrientation(value);
localStorage.setItem("surveyOrientation", value);
};
return (
@@ -213,49 +126,51 @@ export default function SurveyFilters({
type="text"
className="border-none bg-transparent placeholder:text-sm"
placeholder="Search by survey name"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<FilterDropdown
<SurveyFilterDropdown
title="Created By"
id="creatorDropdown"
id="createdBy"
options={creatorOptions}
selectedOptions={createdByFilter}
setSelectedOptions={setCreatedByFilter}
isOpen={dropdownOpenStates.get("creatorDropdown")}
selectedOptions={createdBy}
setSelectedOptions={handleCreatedByChange}
isOpen={dropdownOpenStates.get("createdBy")}
toggleDropdown={toggleDropdown}
/>
</div>
<div>
<FilterDropdown
<SurveyFilterDropdown
title="Status"
id="statusDropdown"
id="status"
options={statusOptions}
selectedOptions={statusFilters}
setSelectedOptions={setStatusFilters}
isOpen={dropdownOpenStates.get("statusDropdown")}
selectedOptions={status}
setSelectedOptions={handleStatusChange}
isOpen={dropdownOpenStates.get("status")}
toggleDropdown={toggleDropdown}
/>
</div>
<div>
<FilterDropdown
<SurveyFilterDropdown
title="Type"
id="typeDropdown"
id="type"
options={typeOptions}
selectedOptions={typeFilters}
setSelectedOptions={setTypeFilters}
isOpen={dropdownOpenStates.get("typeDropdown")}
selectedOptions={type}
setSelectedOptions={handleTypeChange}
isOpen={dropdownOpenStates.get("type")}
toggleDropdown={toggleDropdown}
/>
</div>
{(createdByFilter.length > 0 || statusFilters.length > 0 || typeFilters.length > 0) && (
{(createdBy.length > 0 || status.length > 0 || type.length > 0) && (
<Button
variant="darkCTA"
size="sm"
onClick={() => {
setCreatedByFilter([]);
setStatusFilters([]);
setTypeFilters([]);
setSurveyFilters(initialFilters);
}}
className="h-8"
EndIcon={X}
@@ -271,7 +186,7 @@ export default function SurveyFilters({
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"}`}
onClick={() => setOrientation("list")}>
onClick={() => handleOrientationChange("list")}>
<Equal className="h-5 w-5" />
</div>
</TooltipRenderer>
@@ -282,7 +197,7 @@ export default function SurveyFilters({
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"}`}
onClick={() => setOrientation("grid")}>
onClick={() => handleOrientationChange("grid")}>
<Grid2X2 className="h-5 w-5" />
</div>
</TooltipRenderer>
@@ -293,16 +208,25 @@ export default function SurveyFilters({
className="surveyFilterDropdown h-full cursor-pointer border border-slate-700 outline-none hover:bg-slate-900">
<div className="min-w-auto h-8 rounded-md border sm:flex sm:px-2">
<div className="hidden w-full items-center justify-between hover:text-white sm:flex">
<span className="text-sm ">Sort by: {sortBy.label}</span>
<span className="text-sm ">
Sort by: {sortOptions.find((option) => option.value === sortBy)?.label}
</span>
<ChevronDownIcon className="ml-2 h-4 w-4" />
</div>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-slate-900 ">
{sortOptions.map(renderSortOption)}
{sortOptions.map((option) => (
<SortOption
option={option}
key={option.label}
sortBy={surveyFilters.sortBy}
handleSortChange={handleSortChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
};

View File

@@ -1,15 +1,16 @@
"use client";
import { PlusIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys";
import { TSurvey, TSurveyFilters } from "@formbricks/types/surveys";
import { Button } from "../v2/Button";
import { getSurveysAction } from "./actions";
import SurveyCard from "./components/SurveyCard";
import SurveyFilters from "./components/SurveyFilters";
import { SurveyCard } from "./components/SurveyCard";
import { SurveyFilters } from "./components/SurveyFilters";
import { getFormattedFilters } from "./util";
interface SurveysListProps {
environment: TEnvironment;
@@ -20,50 +21,70 @@ interface SurveysListProps {
surveysPerPage: number;
}
export default function SurveysList({
export const initialFilters: TSurveyFilters = {
name: "",
createdBy: [],
status: [],
type: [],
sortBy: "updatedAt",
};
export const SurveysList = ({
environment,
otherEnvironment,
isViewer,
WEBAPP_URL,
userId,
surveysPerPage: surveysLimit,
}: SurveysListProps) {
}: SurveysListProps) => {
const [surveys, setSurveys] = useState<TSurvey[]>([]);
const [isFetching, setIsFetching] = useState(true);
const [hasMore, setHasMore] = useState<boolean>(true);
const [filteredSurveys, setFilteredSurveys] = useState<TSurvey[]>(surveys);
const [surveyFilters, setSurveyFilters] = useState<TSurveyFilters>(initialFilters);
// Initialize orientation state with a function that checks if window is defined
const [orientation, setOrientation] = useState(() =>
typeof localStorage !== "undefined" ? localStorage.getItem("surveyOrientation") || "grid" : "grid"
);
const filters = useMemo(() => getFormattedFilters(surveyFilters, userId), [surveyFilters, userId]);
const [orientation, setOrientation] = useState("");
// Save orientation to localStorage
useEffect(() => {
localStorage.setItem("surveyOrientation", orientation);
}, [orientation]);
// 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");
}
}, []);
useEffect(() => {
async function fetchInitialSurveys() {
setIsFetching(true);
const res = await getSurveysAction(environment.id, surveysLimit);
if (res.length < surveysLimit) setHasMore(false);
const res = await getSurveysAction(environment.id, surveysLimit, undefined, filters);
if (res.length < surveysLimit) {
setHasMore(false);
} else {
setHasMore(true);
}
setSurveys(res);
setIsFetching(false);
}
fetchInitialSurveys();
}, [environment.id, surveysLimit]);
}, [environment.id, surveysLimit, filters]);
const fetchNextPage = useCallback(async () => {
setIsFetching(true);
const newSurveys = await getSurveysAction(environment.id, surveysLimit, surveys.length);
const newSurveys = await getSurveysAction(environment.id, surveysLimit, surveys.length, filters);
if (newSurveys.length === 0 || newSurveys.length < surveysLimit) {
setHasMore(false);
} else {
setHasMore(true);
}
setSurveys([...surveys, ...newSurveys]);
setIsFetching(false);
}, [environment.id, surveys, surveysLimit]);
}, [environment.id, surveys, surveysLimit, filters]);
const handleDeleteSurvey = async (surveyId: string) => {
const newSurveys = surveys.filter((survey) => survey.id !== surveyId);
@@ -87,13 +108,12 @@ export default function SurveysList({
</Button>
</div>
<SurveyFilters
surveys={surveys}
setFilteredSurveys={setFilteredSurveys}
orientation={orientation}
setOrientation={setOrientation}
userId={userId}
surveyFilters={surveyFilters}
setSurveyFilters={setSurveyFilters}
/>
{filteredSurveys.length > 0 ? (
{surveys.length > 0 ? (
<div>
{orientation === "list" && (
<div className="flex-col space-y-3">
@@ -104,7 +124,7 @@ export default function SurveysList({
<div className="col-span-2">Updated at</div>
</div>
</div>
{filteredSurveys.map((survey) => {
{surveys.map((survey) => {
return (
<SurveyCard
key={survey.id}
@@ -123,7 +143,7 @@ export default function SurveysList({
)}
{orientation === "grid" && (
<div className="grid grid-cols-4 place-content-stretch gap-4 lg:grid-cols-6 ">
{filteredSurveys.map((survey) => {
{surveys.map((survey) => {
return (
<SurveyCard
key={survey.id}
@@ -158,4 +178,4 @@ export default function SurveysList({
)}
</div>
);
}
};

View File

@@ -0,0 +1,30 @@
import { TSurveyFilterCriteria, TSurveyFilters } from "@formbricks/types/surveys";
export const getFormattedFilters = (surveyFilters: TSurveyFilters, userId: string): TSurveyFilterCriteria => {
const filters: TSurveyFilterCriteria = {};
if (surveyFilters.name) {
filters.name = surveyFilters.name;
}
if (surveyFilters.status && surveyFilters.status.length) {
filters.status = surveyFilters.status;
}
if (surveyFilters.type && surveyFilters.type.length) {
filters.type = surveyFilters.type;
}
if (surveyFilters.createdBy && surveyFilters.createdBy.length) {
filters.createdBy = {
userId: userId,
value: surveyFilters.createdBy,
};
}
if (surveyFilters.sortBy) {
filters.sortBy = surveyFilters.sortBy;
}
return filters;
};