mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-19 21:40:32 -06:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
24
packages/ui/SurveysList/components/SortOption.tsx
Normal file
24
packages/ui/SurveysList/components/SortOption.tsx
Normal 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>
|
||||
);
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
59
packages/ui/SurveysList/components/SurveyFilterDropdown.tsx
Normal file
59
packages/ui/SurveysList/components/SurveyFilterDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
30
packages/ui/SurveysList/util.ts
Normal file
30
packages/ui/SurveysList/util.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user