feat: migrate survey overview to v3 APIs (#7741)

This commit is contained in:
Bhagya Amarasinghe
2026-04-17 15:15:12 +05:30
committed by GitHub
parent b1cee91ad9
commit 6fcb6863bd
62 changed files with 2790 additions and 1360 deletions
@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { V3ApiError, getV3ApiErrorMessage, parseV3ApiError } from "@/modules/api/lib/v3-client";
describe("parseV3ApiError", () => {
test("parses RFC 9457 error responses into a typed V3ApiError", async () => {
const response = new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
requestId: "req_1",
invalid_params: [{ name: "surveyId", reason: "Invalid id" }],
}),
{
status: 403,
headers: {
"Content-Type": "application/problem+json",
"X-Request-Id": "req_1",
},
}
);
const error = await parseV3ApiError(response);
expect(error).toBeInstanceOf(V3ApiError);
expect(error.status).toBe(403);
expect(error.detail).toBe("You are not authorized to access this resource");
expect(error.code).toBe("forbidden");
expect(error.requestId).toBe("req_1");
expect(error.invalid_params).toEqual([{ name: "surveyId", reason: "Invalid id" }]);
});
test("falls back to a provided fallback message", () => {
expect(getV3ApiErrorMessage(new Error("boom"), "fallback")).toBe("boom");
expect(getV3ApiErrorMessage("bad", "fallback")).toBe("fallback");
});
});
+74
View File
@@ -0,0 +1,74 @@
export type TV3InvalidParam = {
name: string;
reason: string;
};
type TV3ProblemBody = {
status?: number;
detail?: string;
code?: string;
requestId?: string;
invalid_params?: TV3InvalidParam[];
};
export class V3ApiError extends Error {
status: number;
code?: string;
requestId?: string;
invalid_params?: TV3InvalidParam[];
constructor({
status,
detail,
code,
requestId,
invalid_params,
}: {
status: number;
detail: string;
code?: string;
requestId?: string;
invalid_params?: TV3InvalidParam[];
}) {
super(detail);
this.name = "V3ApiError";
this.status = status;
this.code = code;
this.requestId = requestId;
this.invalid_params = invalid_params;
}
get detail(): string {
return this.message;
}
}
export function getV3ApiErrorMessage(error: unknown, fallbackMessage: string): string {
if (error instanceof V3ApiError) {
return error.detail;
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallbackMessage;
}
export async function parseV3ApiError(response: Response): Promise<V3ApiError> {
let problemBody: TV3ProblemBody | undefined;
try {
problemBody = (await response.json()) as TV3ProblemBody;
} catch {
problemBody = undefined;
}
return new V3ApiError({
status: problemBody?.status ?? response.status,
detail: problemBody?.detail ?? response.statusText ?? "An unexpected error occurred.",
code: problemBody?.code,
requestId: problemBody?.requestId ?? response.headers.get("X-Request-Id") ?? undefined,
invalid_params: problemBody?.invalid_params,
});
}
+140
View File
@@ -0,0 +1,140 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey } from "./surveys";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
},
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
const environmentId = "clq5n7p1q0000m7z0h5p6g3r3";
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
const mockDeletedSurveyAppPrivateSegment = {
id: surveyId,
environmentId,
type: "app",
segment: { id: segmentId, isPrivate: true },
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
};
const mockDeletedSurveyLink = {
id: surveyId,
environmentId,
type: "link",
segment: null,
triggers: [],
};
describe("deleteSurvey", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("should delete a link survey without a segment", async () => {
const deleteMock = vi.fn().mockResolvedValue(mockDeletedSurveyLink);
const segmentDeleteMock = vi.fn();
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
callback({
survey: { delete: deleteMock },
segment: { delete: segmentDeleteMock },
} as never)
);
const deletedSurvey = await deleteSurvey(surveyId);
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
expect(deleteMock).toHaveBeenCalledWith({
where: { id: surveyId },
include: {
segment: true,
triggers: { include: { actionClass: true } },
},
});
expect(segmentDeleteMock).not.toHaveBeenCalled();
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
});
test("should delete a private segment for app surveys", async () => {
const deleteMock = vi.fn().mockResolvedValue(mockDeletedSurveyAppPrivateSegment);
const segmentDeleteMock = vi.fn().mockResolvedValue({ id: segmentId });
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
callback({
survey: { delete: deleteMock },
segment: { delete: segmentDeleteMock },
} as never)
);
const deletedSurvey = await deleteSurvey(surveyId);
expect(segmentDeleteMock).toHaveBeenCalledWith({ where: { id: segmentId } });
expect(deletedSurvey).toEqual(mockDeletedSurveyAppPrivateSegment);
});
test("should map Prisma P2025 during survey deletion to ResourceNotFoundError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "4.0.0",
});
vi.mocked(prisma.$transaction).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(ResourceNotFoundError);
expect(logger.warn).toHaveBeenCalledWith({ surveyId }, "Survey not found during delete");
expect(logger.error).not.toHaveBeenCalled();
});
test("should handle non-P2025 PrismaClientKnownRequestError during survey deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Constraint failed", {
code: "P2003",
clientVersion: "4.0.0",
});
vi.mocked(prisma.$transaction).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
});
test("should handle generic errors during deletion", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.$transaction).mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
});
test("should throw validation error for invalid surveyId", async () => {
const invalidSurveyId = "invalid-id";
const validationError = new Error("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
+51
View File
@@ -0,0 +1,51 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, ZId]);
try {
return await prisma.$transaction(async (tx) => {
const deletedSurvey = await tx.survey.delete({
where: {
id: surveyId,
},
include: {
segment: true,
triggers: {
include: {
actionClass: true,
},
},
},
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
await tx.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
}
return deletedSurvey;
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2025") {
logger.warn({ surveyId }, "Survey not found during delete");
throw new ResourceNotFoundError("Survey", surveyId);
}
logger.error({ error, surveyId }, "Error deleting survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
+1 -127
View File
@@ -2,52 +2,18 @@
import { z } from "zod";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSurveyFilterCriteria } from "@formbricks/types/surveys/types";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
getEnvironmentIdFromSurveyId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromSurveyId,
getProjectIdFromEnvironmentId,
getProjectIdFromSurveyId,
} from "@/lib/utils/helper";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
import { getUserProjects } from "@/modules/survey/list/lib/project";
import {
copySurveyToOtherEnvironment,
deleteSurvey,
getSurvey,
getSurveys,
} from "@/modules/survey/list/lib/survey";
const ZGetSurveyAction = z.object({
surveyId: z.cuid2(),
});
export const getSurveyAction = authenticatedActionClient
.inputSchema(ZGetSurveyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
return await getSurvey(parsedInput.surveyId);
});
import { copySurveyToOtherEnvironment } from "@/modules/survey/list/lib/survey";
const ZCopySurveyToOtherEnvironmentAction = z.object({
surveyId: z.cuid2(),
@@ -127,62 +93,6 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient
})
);
const ZGetProjectsByEnvironmentIdAction = z.object({
environmentId: z.cuid2(),
});
export const getProjectsByEnvironmentIdAction = authenticatedActionClient
.inputSchema(ZGetProjectsByEnvironmentIdAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
return await getUserProjects(ctx.user.id, organizationId);
});
const ZDeleteSurveyAction = z.object({
surveyId: z.cuid2(),
});
export const deleteSurveyAction = authenticatedActionClient.inputSchema(ZDeleteSurveyAction).action(
withAuditLogging("deleted", "survey", async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = await getSurvey(parsedInput.surveyId);
return await deleteSurvey(parsedInput.surveyId);
})
);
const ZGenerateSingleUseIdAction = z.object({
surveyId: z.cuid2(),
isEncrypted: z.boolean(),
@@ -210,39 +120,3 @@ export const generateSingleUseIdsAction = authenticatedActionClient
return generateSurveySingleUseIds(parsedInput.count, parsedInput.isEncrypted);
});
const ZGetSurveysAction = z.object({
environmentId: z.cuid2(),
limit: z.number().optional(),
offset: z.number().optional(),
filterCriteria: ZSurveyFilterCriteria.optional(),
});
export const getSurveysAction = authenticatedActionClient
.inputSchema(ZGetSurveysAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
data: parsedInput.filterCriteria,
schema: ZSurveyFilterCriteria,
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
return await getSurveys(
parsedInput.environmentId,
parsedInput.limit,
parsedInput.offset,
parsedInput.filterCriteria
);
});
@@ -1,223 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertCircleIcon } from "lucide-react";
import { useFieldArray, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { TUserProject } from "@/modules/survey/list/types/projects";
import { TSurvey, TSurveyCopyFormData, ZSurveyCopyFormValidation } from "@/modules/survey/list/types/surveys";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import { FormControl, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
import { Label } from "@/modules/ui/components/label";
interface CopySurveyFormProps {
readonly defaultProjects: TUserProject[];
readonly survey: TSurvey;
readonly onCancel: () => void;
readonly setOpen: (value: boolean) => void;
}
interface EnvironmentCheckboxProps {
readonly environmentId: string;
readonly environmentType: string;
readonly fieldValue: string[];
readonly onChange: (value: string[]) => void;
}
function EnvironmentCheckbox({
environmentId,
environmentType,
fieldValue,
onChange,
}: EnvironmentCheckboxProps) {
const handleCheckedChange = () => {
if (fieldValue.includes(environmentId)) {
onChange(fieldValue.filter((id) => id !== environmentId));
} else {
onChange([...fieldValue, environmentId]);
}
};
return (
<FormItem>
<div className="flex items-center">
<FormControl>
<div className="flex items-center">
<Checkbox
type="button"
checked={fieldValue.includes(environmentId)}
onCheckedChange={handleCheckedChange}
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
id={environmentId}
/>
<Label htmlFor={environmentId}>
<p className="text-sm font-medium capitalize text-slate-900">{environmentType}</p>
</Label>
</div>
</FormControl>
</div>
</FormItem>
);
}
interface EnvironmentCheckboxGroupProps {
readonly project: TUserProject;
readonly form: ReturnType<typeof useForm<TSurveyCopyFormData>>;
readonly projectIndex: number;
}
function EnvironmentCheckboxGroup({ project, form, projectIndex }: EnvironmentCheckboxGroupProps) {
return (
<div className="flex flex-col gap-4">
{project.environments.map((environment) => (
<FormField
key={environment.id}
control={form.control}
name={`projects.${projectIndex}.environments`}
render={({ field }) => (
<EnvironmentCheckbox
environmentId={environment.id}
environmentType={environment.type}
fieldValue={field.value}
onChange={field.onChange}
/>
)}
/>
))}
</div>
);
}
export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: CopySurveyFormProps) => {
const { t } = useTranslation();
const filteredProjects = defaultProjects.map((project) => ({
...project,
environments: project.environments.filter((env) => env.id !== survey.environmentId),
}));
const form = useForm<TSurveyCopyFormData>({
resolver: zodResolver(ZSurveyCopyFormValidation),
defaultValues: {
projects: filteredProjects.map((project) => ({
project: project.id,
environments: [],
})),
},
});
const formFields = useFieldArray({
name: "projects",
control: form.control,
});
async function onSubmit(data: TSurveyCopyFormData) {
const filteredData = data.projects.filter((project) => project.environments.length > 0);
try {
const copyOperationsWithMetadata = filteredData.flatMap((projectData) => {
const project = filteredProjects.find((p) => p.id === projectData.project);
return projectData.environments.map((environmentId) => {
const environment =
project?.environments[0]?.id === environmentId
? project?.environments[0]
: project?.environments[1];
return {
projectName: project?.name ?? "Unknown Project",
environmentType: environment?.type ?? "unknown",
environmentId,
};
});
});
const results: Awaited<ReturnType<typeof copySurveyToOtherEnvironmentAction>>[] = [];
for (const item of copyOperationsWithMetadata) {
const result = await copySurveyToOtherEnvironmentAction({
surveyId: survey.id,
targetEnvironmentId: item.environmentId,
});
results.push(result);
}
let successCount = 0;
let errorCount = 0;
const errorsIndexes: number[] = [];
results.forEach((result, index) => {
if (result?.data) {
successCount++;
} else {
errorsIndexes.push(index);
errorCount++;
}
});
if (successCount > 0) {
if (errorCount === 0) {
toast.success(t("environments.surveys.copy_survey_success"));
} else {
toast.error(
t("environments.surveys.copy_survey_partially_success", {
success: successCount,
error: errorCount,
}),
{
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
}
);
}
}
if (errorsIndexes.length > 0) {
errorsIndexes.forEach((index, idx) => {
const { projectName, environmentType } = copyOperationsWithMetadata[index];
const result = results[index];
const errorMessage = getFormattedErrorMessage(result);
toast.error(`[${projectName}] - [${environmentType}] - ${errorMessage}`, {
duration: 2000 + 2000 * idx,
});
});
}
} catch (error) {
toast.error(t("environments.surveys.copy_survey_error"));
} finally {
setOpen(false);
}
}
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex h-full w-full flex-col bg-white">
<div className="flex-1 space-y-8 overflow-y-auto">
{formFields.fields.map((field, projectIndex) => {
const project = filteredProjects.find((project) => project.id === field.project);
if (!project) return null;
return (
<div key={project.id}>
<div className="flex flex-col gap-4">
<div className="w-fit">
<p className="text-base font-semibold text-slate-900">{project.name}</p>
</div>
<EnvironmentCheckboxGroup project={project} form={form} projectIndex={projectIndex} />
</div>
</div>
);
})}
</div>
<div className="sticky bottom-0 flex justify-end space-x-2 bg-white pt-4">
<Button type="button" onClick={onCancel} variant="secondary">
{t("common.cancel")}
</Button>
<Button type="submit">{t("environments.surveys.copy_survey")}</Button>
</div>
</form>
</FormProvider>
);
};
@@ -1,44 +0,0 @@
"use client";
import { MousePointerClickIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import SurveyCopyOptions from "./survey-copy-options";
interface CopySurveyModalProps {
open: boolean;
setOpen: (value: boolean) => void;
survey: TSurvey;
}
export const CopySurveyModal = ({ open, setOpen, survey }: CopySurveyModalProps) => {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[600px]">
<DialogHeader>
<MousePointerClickIcon />
<DialogTitle>{t("environments.surveys.copy_survey")}</DialogTitle>
<DialogDescription>{t("environments.surveys.copy_survey_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<SurveyCopyOptions
survey={survey}
environmentId={survey.environmentId}
onCancel={() => setOpen(false)}
setOpen={setOpen}
/>
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -1,11 +1,12 @@
"use client";
import { TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
import { TSortOption } from "@formbricks/types/surveys/types";
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { DropdownMenuItem } from "@/modules/ui/components/dropdown-menu";
interface SortOptionProps {
option: TSortOption;
sortBy: TSurveyFilters["sortBy"];
sortBy: TSurveyOverviewFilters["sortBy"];
handleSortChange: (option: TSortOption) => void;
}
@@ -8,27 +8,25 @@ import { cn } from "@/lib/cn";
import { timeSince } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import { SurveyTypeIndicator } from "@/modules/survey/list/components/survey-type-indicator";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { TSurveyListItem } from "@/modules/survey/list/types/survey-overview";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { SurveyDropDownMenu } from "./survey-dropdown-menu";
interface SurveyCardProps {
survey: TSurvey;
survey: TSurveyListItem;
environmentId: string;
isReadOnly: boolean;
publicDomain: string;
deleteSurvey: (surveyId: string) => void;
isReadOnly: boolean;
deleteSurvey: (surveyId: string) => Promise<void>;
locale: TUserLocale;
onSurveysCopied?: () => void;
}
export const SurveyCard = ({
survey,
environmentId,
isReadOnly,
publicDomain,
isReadOnly,
deleteSurvey,
locale,
onSurveysCopied,
}: SurveyCardProps) => {
const { t } = useTranslation();
const surveyStatusLabel = (() => {
@@ -56,43 +54,53 @@ export const SurveyCard = ({
const isDraftAndReadOnly = survey.status === "draft" && isReadOnly;
const CardContent = (
<>
const CardBody = (
<div
className={cn(
"grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 pr-8 shadow-sm transition-colors ease-in-out",
!isDraftAndReadOnly && "hover:border-slate-400"
)}>
<div className="col-span-2 flex max-w-full items-center justify-self-start text-sm font-medium text-slate-900">
<div className="w-full truncate">{survey.name}</div>
</div>
<div
className={cn(
"grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 pr-8 shadow-sm transition-colors ease-in-out",
!isDraftAndReadOnly && "hover:border-slate-400"
"col-span-1 flex w-fit items-center gap-2 whitespace-nowrap rounded-full py-1 pl-1 pr-2 text-sm text-slate-800",
survey.status === "inProgress" && "bg-emerald-50",
survey.status === "completed" && "bg-slate-200",
survey.status === "draft" && "bg-slate-100",
survey.status === "paused" && "bg-slate-100"
)}>
<div className="col-span-2 flex max-w-full items-center justify-self-start text-sm font-medium text-slate-900">
<div className="w-full truncate">{survey.name}</div>
</div>
<div
className={cn(
"col-span-1 flex w-fit items-center gap-2 whitespace-nowrap rounded-full py-1 pl-1 pr-2 text-sm text-slate-800",
surveyStatusLabel === "In Progress" && "bg-emerald-50",
surveyStatusLabel === "Completed" && "bg-slate-200",
surveyStatusLabel === "Draft" && "bg-slate-100",
surveyStatusLabel === "Paused" && "bg-slate-100"
)}>
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}{" "}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{survey.responseCount}
</div>
<div className="col-span-1 flex justify-between">
<SurveyTypeIndicator type={survey.type} />
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{formatDateForDisplay(survey.createdAt, locale)}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{timeSince(survey.updatedAt.toString(), locale)}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{survey.creator ? survey.creator.name : "-"}
</div>
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}{" "}
</div>
<button className="absolute right-3 top-3.5" onClick={(e) => e.stopPropagation()}>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{survey.responseCount}
</div>
<div className="col-span-1 flex justify-between">
<SurveyTypeIndicator type={survey.type} />
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{formatDateForDisplay(survey.createdAt, locale)}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{timeSince(survey.updatedAt.toString(), locale)}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{survey.creator ? survey.creator.name : "-"}
</div>
</div>
);
return (
<div className="relative block">
{isDraftAndReadOnly ? (
CardBody
) : (
<Link href={linkHref} key={survey.id} className="block">
{CardBody}
</Link>
)}
<div className="absolute right-3 top-3.5">
<SurveyDropDownMenu
survey={survey}
key={`surveys-${survey.id}`}
@@ -101,17 +109,8 @@ export const SurveyCard = ({
disabled={isDraftAndReadOnly}
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
deleteSurvey={deleteSurvey}
onSurveysCopied={onSurveysCopied}
/>
</button>
</>
);
return isDraftAndReadOnly ? (
<div className="relative block">{CardContent}</div>
) : (
<Link href={linkHref} key={survey.id} className="relative block">
{CardContent}
</Link>
</div>
</div>
);
};
@@ -1,50 +0,0 @@
"use client";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getProjectsByEnvironmentIdAction } from "@/modules/survey/list/actions";
import { TUserProject } from "@/modules/survey/list/types/projects";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { CopySurveyForm } from "./copy-survey-form";
interface SurveyCopyOptionsProps {
survey: TSurvey;
environmentId: string;
onCancel: () => void;
setOpen: (value: boolean) => void;
}
const SurveyCopyOptions = ({ environmentId, survey, onCancel, setOpen }: SurveyCopyOptionsProps) => {
const [projects, setProjects] = useState<TUserProject[]>([]);
const [projectLoading, setProjectLoading] = useState(true);
useEffect(() => {
const fetchProjects = async () => {
const getProjectsByEnvironmentIdResponse = await getProjectsByEnvironmentIdAction({ environmentId });
if (getProjectsByEnvironmentIdResponse?.data) {
setProjects(getProjectsByEnvironmentIdResponse?.data);
} else {
const errorMessage = getFormattedErrorMessage(getProjectsByEnvironmentIdResponse);
toast.error(errorMessage);
}
setProjectLoading(false);
};
fetchProjects();
}, [environmentId]);
if (projectLoading) {
return (
<div className="relative flex h-full min-h-96 w-full items-center justify-center bg-white pb-12">
<Loader2 className="animate-spin" />
</div>
);
}
return <CopySurveyForm defaultProjects={projects} survey={survey} onCancel={onCancel} setOpen={setOpen} />;
};
export default SurveyCopyOptions;
@@ -1,14 +1,6 @@
"use client";
import {
ArrowUpFromLineIcon,
CopyIcon,
EyeIcon,
LinkIcon,
MoreVertical,
SquarePenIcon,
TrashIcon,
} from "lucide-react";
import { EyeIcon, LinkIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
@@ -16,15 +8,10 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import {
copySurveyToOtherEnvironmentAction,
deleteSurveyAction,
getSurveyAction,
} from "@/modules/survey/list/actions";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { TSurveyListItem } from "@/modules/survey/list/types/survey-overview";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
@@ -33,16 +20,14 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { CopySurveyModal } from "./copy-survey-modal";
interface SurveyDropDownMenuProps {
environmentId: string;
survey: TSurvey;
survey: TSurveyListItem;
publicDomain: string;
disabled?: boolean;
isSurveyCreationDeletionDisabled?: boolean;
deleteSurvey: (surveyId: string) => void;
onSurveysCopied?: () => void;
deleteSurvey: (surveyId: string) => Promise<void>;
}
export const SurveyDropDownMenu = ({
@@ -52,32 +37,29 @@ export const SurveyDropDownMenu = ({
disabled,
isSurveyCreationDeletionDisabled,
deleteSurvey,
onSurveysCopied,
}: SurveyDropDownMenuProps) => {
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const router = useRouter();
const surveyLink = useMemo(() => publicDomain + "/s/" + survey.id, [survey.id, publicDomain]);
const editHref = `/environments/${environmentId}/surveys/${survey.id}/edit`;
const surveyLink = useMemo(() => `${publicDomain}/s/${survey.id}`, [publicDomain, survey.id]);
const isSingleUseEnabled = survey.singleUse?.enabled ?? false;
const canManageSurvey = !isSurveyCreationDeletionDisabled;
const canPreviewOrCopyLink = survey.type === "link" && survey.status !== "draft";
const hasVisibleActions = canManageSurvey || canPreviewOrCopyLink;
const handleDeleteSurvey = async (surveyId: string) => {
setLoading(true);
try {
const result = await deleteSurveyAction({ surveyId });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
deleteSurvey(surveyId);
await deleteSurvey(surveyId);
toast.success(t("environments.surveys.survey_deleted_successfully"));
} catch (error) {
toast.error(t("environments.surveys.error_deleting_survey"));
toast.error(getV3ApiErrorMessage(error, t("environments.surveys.error_deleting_survey")));
} finally {
setLoading(false);
}
@@ -87,147 +69,93 @@ export const SurveyDropDownMenu = ({
try {
e.preventDefault();
setIsDropDownOpen(false);
// For single-use surveys, this button is disabled, so we just copy the base link
const copiedLink = copySurveyLink(surveyLink);
navigator.clipboard.writeText(copiedLink);
await navigator.clipboard.writeText(copySurveyLink(surveyLink));
toast.success(t("common.copied_to_clipboard"));
} catch (error) {
logger.error(error);
toast.error(t("environments.surveys.summary.failed_to_copy_link"));
toast.error(t("common.something_went_wrong_please_try_again"));
}
};
const duplicateSurveyAndRefresh = async (surveyId: string) => {
setLoading(true);
try {
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
surveyId,
targetEnvironmentId: environmentId,
});
if (duplicatedSurveyResponse?.serverError) {
toast.error(getFormattedErrorMessage(duplicatedSurveyResponse));
} else if (duplicatedSurveyResponse?.data) {
const transformedDuplicatedSurvey = await getSurveyAction({
surveyId: duplicatedSurveyResponse.data.id,
});
if (transformedDuplicatedSurvey?.data) {
onSurveysCopied?.();
}
toast.success(t("environments.surveys.survey_duplicated_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(duplicatedSurveyResponse);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("environments.surveys.survey_duplication_error"));
}
setLoading(false);
};
const handleEditforActiveSurvey = (e: React.MouseEvent) => {
e.preventDefault();
setIsDropDownOpen(false);
setIsCautionDialogOpen(true);
};
if (!hasVisibleActions) {
return null;
}
return (
<div
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
data-testid="survey-dropdown-menu">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
<div
<button
type="button"
data-testid="survey-dropdown-trigger"
aria-label={t("environments.surveys.open_options")}
className={cn(
"rounded-lg border bg-white p-2",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-slate-50"
)}>
<span className="sr-only">{t("environments.surveys.open_options")}</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max">
<DropdownMenuGroup>
{!isSurveyCreationDeletionDisabled && (
<>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}
onClick={survey.responseCount > 0 ? handleEditforActiveSurvey : undefined}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
duplicateSurveyAndRefresh(survey.id);
}}>
<CopyIcon className="mr-2 h-4 w-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
</>
{canManageSurvey && (
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={editHref}
onClick={survey.responseCount > 0 ? handleEditforActiveSurvey : undefined}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</Link>
</DropdownMenuItem>
)}
{!isSurveyCreationDeletionDisabled && (
{canPreviewOrCopyLink && (
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
disabled={loading}
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setIsCopyFormOpen(true);
const previewUrl = new URL(surveyLink);
previewUrl.searchParams.set("preview", "true");
globalThis.window.open(previewUrl.toString(), "_blank");
}}>
<ArrowUpFromLineIcon className="mr-2 h-4 w-4" />
{t("common.copy")}...
<EyeIcon className="mr-2 h-4 w-4" />
{t("common.preview")}
</button>
</DropdownMenuItem>
)}
{survey.type === "link" && survey.status !== "draft" && (
<>
<DropdownMenuItem>
<button
type="button"
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
const previewUrl = surveyLink + "?preview=true";
window.open(previewUrl, "_blank");
}}>
<EyeIcon className="mr-2 h-4 w-4" />
{t("common.preview_survey")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
data-testid="copy-link"
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
onClick={async (e) => handleCopyLink(e)}>
<LinkIcon className="mr-2 h-4 w-4" />
{t("common.copy_link")}
</button>
</DropdownMenuItem>
</>
{canPreviewOrCopyLink && (
<DropdownMenuItem>
<button
type="button"
data-testid="copy-link"
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
onClick={handleCopyLink}>
<LinkIcon className="mr-2 h-4 w-4" />
{t("common.copy_link")}
</button>
</DropdownMenuItem>
)}
{!isSurveyCreationDeletionDisabled && (
{canManageSurvey && (
<DropdownMenuItem>
<button
type="button"
@@ -246,7 +174,7 @@ export const SurveyDropDownMenu = ({
</DropdownMenuContent>
</DropdownMenu>
{!isSurveyCreationDeletionDisabled && (
{canManageSurvey && (
<DeleteDialog
deleteWhat={t("common.survey")}
open={isDeleteDialogOpen}
@@ -257,26 +185,20 @@ export const SurveyDropDownMenu = ({
/>
)}
{survey.responseCount > 0 && (
{canManageSurvey && survey.responseCount > 0 && (
<EditPublicSurveyAlertDialog
open={isCautionDialogOpen}
setOpen={setIsCautionDialogOpen}
isLoading={loading}
primaryButtonAction={async () => {
await duplicateSurveyAndRefresh(survey.id);
setIsCautionDialogOpen(false);
router.push(editHref);
}}
primaryButtonText={t("common.duplicate")}
secondaryButtonAction={() =>
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`)
}
secondaryButtonText={t("common.edit")}
primaryButtonText={t("common.edit")}
secondaryButtonAction={() => setIsCautionDialogOpen(false)}
secondaryButtonText={t("common.cancel")}
/>
)}
{isCopyFormOpen && (
<CopySurveyModal open={isCopyFormOpen} setOpen={setIsCopyFormOpen} survey={survey} />
)}
</div>
);
};
@@ -12,7 +12,7 @@ import {
interface SurveyFilterDropdownProps {
title: string;
id: "createdBy" | "status" | "type";
id: "status" | "type";
options: TFilterOption[];
selectedOptions: string[];
setSelectedOptions: (value: string) => void;
@@ -2,14 +2,14 @@
import { TFunction } from "i18next";
import { ChevronDownIcon, X } from "lucide-react";
import { useState } from "react";
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDebounce } from "react-use";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TFilterOption, TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import type { TProjectConfigChannel } from "@formbricks/types/project";
import type { TFilterOption, TSortOption } from "@formbricks/types/surveys/types";
import { SortOption } from "@/modules/survey/list/components/sort-option";
import { initialFilters } from "@/modules/survey/list/lib/constants";
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
@@ -20,16 +20,11 @@ import { SearchBar } from "@/modules/ui/components/search-bar";
import { SurveyFilterDropdown } from "./survey-filter-dropdown";
interface SurveyFilterProps {
surveyFilters: TSurveyFilters;
setSurveyFilters: React.Dispatch<React.SetStateAction<TSurveyFilters>>;
surveyFilters: TSurveyOverviewFilters;
setSurveyFilters: Dispatch<SetStateAction<TSurveyOverviewFilters>>;
currentProjectChannel: TProjectConfigChannel;
}
const getCreatorOptions = (t: TFunction): TFilterOption[] => [
{ label: t("common.you"), value: "you" },
{ label: t("common.others"), value: "others" },
];
const getStatusOptions = (t: TFunction): TFilterOption[] => [
{ label: t("common.draft"), value: "draft" },
{ label: t("common.in_progress"), value: "inProgress" },
@@ -61,10 +56,10 @@ export const SurveyFilters = ({
setSurveyFilters,
currentProjectChannel,
}: SurveyFilterProps) => {
const { createdBy, sortBy, status, type } = surveyFilters;
const [name, setName] = useState("");
const { sortBy, status, type } = surveyFilters;
const [name, setName] = useState(surveyFilters.name);
const { t } = useTranslation();
useDebounce(() => setSurveyFilters((prev) => ({ ...prev, name: name })), 800, [name]);
useDebounce(() => setSurveyFilters((prev) => ({ ...prev, name })), 800, [name]);
const [dropdownOpenStates, setDropdownOpenStates] = useState(new Map());
@@ -73,20 +68,14 @@ export const SurveyFilters = ({
{ label: t("common.app"), value: "app" },
];
useEffect(() => {
setName(surveyFilters.name);
}, [surveyFilters.name]);
const toggleDropdown = (id: string) => {
setDropdownOpenStates(new Map(dropdownOpenStates).set(id, !dropdownOpenStates.get(id)));
};
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 handleStatusChange = (value: string) => {
if (value === "inProgress" || value === "paused" || value === "completed" || value === "draft") {
if (status.includes(value)) {
@@ -120,17 +109,6 @@ export const SurveyFilters = ({
placeholder={t("environments.surveys.search_by_survey_name")}
className="border-slate-700"
/>
<div>
<SurveyFilterDropdown
title={t("common.created_by")}
id="createdBy"
options={getCreatorOptions(t)}
selectedOptions={createdBy}
setSelectedOptions={handleCreatedByChange}
isOpen={dropdownOpenStates.get("createdBy")}
toggleDropdown={toggleDropdown}
/>
</div>
<div>
<SurveyFilterDropdown
title={t("common.status")}
@@ -156,13 +134,12 @@ export const SurveyFilters = ({
</div>
)}
{(createdBy.length > 0 || status.length > 0 || type.length > 0 || name) && (
{(status.length > 0 || type.length > 0 || name) && (
<Button
size="sm"
onClick={() => {
setSurveyFilters(initialFilters);
setName(""); // Also clear the search input
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
setName(initialFilters.name);
}}
className="h-8">
{t("common.clear_filters")}
@@ -1,195 +1,244 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { PlusIcon } from "lucide-react";
import Link from "next/link";
import { type ComponentProps, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { wrapThrows } from "@formbricks/types/error-handlers";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TSurveyFilters } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { getSurveysAction } from "@/modules/survey/list/actions";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { useDeleteSurvey } from "@/modules/survey/list/hooks/use-delete-survey";
import { useSurveys } from "@/modules/survey/list/hooks/use-surveys";
import { initialFilters } from "@/modules/survey/list/lib/constants";
import { getFormattedFilters } from "@/modules/survey/list/lib/utils";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import {
hasActiveSurveyFilters,
normalizeSurveyFilters,
parseStoredSurveyFilters,
} from "@/modules/survey/list/lib/utils";
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { TemplateContainerWithPreview } from "@/modules/survey/templates/components/template-container";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SurveyCard } from "./survey-card";
import { SurveyFilters } from "./survey-filters";
import { SurveyLoading } from "./survey-loading";
interface SurveysListProps {
environmentId: string;
isReadOnly: boolean;
publicDomain: string;
environment: ComponentProps<typeof TemplateContainerWithPreview>["environment"];
project: ComponentProps<typeof TemplateContainerWithPreview>["project"];
userId: string;
publicDomain: string;
isReadOnly: boolean;
surveysPerPage: number;
currentProjectChannel: TProjectConfigChannel;
locale: TUserLocale;
}
export const SurveysList = ({
environmentId,
isReadOnly,
publicDomain,
environment,
project,
userId,
surveysPerPage: surveysLimit,
publicDomain,
isReadOnly,
surveysPerPage,
currentProjectChannel,
locale,
}: SurveysListProps) => {
const router = useRouter();
const [surveys, setSurveys] = useState<TSurvey[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [hasMore, setHasMore] = useState<boolean>(false);
const [refreshTrigger, setRefreshTrigger] = useState(false);
const { t } = useTranslation();
const [surveyFilters, setSurveyFilters] = useState<TSurveyFilters>(initialFilters);
const [surveyFilters, setSurveyFilters] = useState<TSurveyOverviewFilters>(initialFilters);
const [isFilterInitialized, setIsFilterInitialized] = useState(false);
const { name, createdBy, status, type, sortBy } = surveyFilters;
const filters = useMemo(
() => getFormattedFilters(surveyFilters, userId),
[name, JSON.stringify(createdBy), JSON.stringify(status), JSON.stringify(type), sortBy, userId]
);
const [parent] = useAutoAnimate();
useEffect(() => {
if (typeof window !== "undefined") {
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);
if (typeof globalThis.window === "undefined") {
return;
}
}, []);
const storedFilters = globalThis.window.localStorage.getItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
const parsedFilters = parseStoredSurveyFilters(storedFilters, currentProjectChannel);
if (storedFilters && !parsedFilters) {
globalThis.window.localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
setSurveyFilters(initialFilters);
} else if (parsedFilters) {
setSurveyFilters(parsedFilters);
}
setIsFilterInitialized(true);
}, [currentProjectChannel]);
const normalizedFilters = useMemo(
() => normalizeSurveyFilters(surveyFilters, currentProjectChannel),
[currentProjectChannel, surveyFilters]
);
useEffect(() => {
if (isFilterInitialized) {
localStorage.setItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS, JSON.stringify(surveyFilters));
if (!isFilterInitialized || typeof globalThis.window === "undefined") {
return;
}
}, [surveyFilters, isFilterInitialized]);
useEffect(() => {
// Wait for filters to be loaded from localStorage before fetching
if (!isFilterInitialized) return;
globalThis.window.localStorage.setItem(
FORMBRICKS_SURVEYS_FILTERS_KEY_LS,
JSON.stringify(normalizedFilters)
);
}, [normalizedFilters, isFilterInitialized]);
const fetchFilteredSurveys = async () => {
setIsFetching(true);
const res = await getSurveysAction({
environmentId,
limit: surveysLimit,
offset: undefined,
filterCriteria: filters,
});
if (res?.data) {
if (res.data.length < surveysLimit) {
setHasMore(false);
} else {
setHasMore(true);
}
setSurveys(res.data);
setIsFetching(false);
}
};
fetchFilteredSurveys();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [environmentId, surveysLimit, filters, refreshTrigger, isFilterInitialized]);
const {
error,
fetchNextPage,
hasNextPage,
isError,
isFetchingNextPage,
isLoading,
queryKey,
refetch,
surveys,
totalCount,
} = useSurveys({
workspaceId: environment.id,
limit: surveysPerPage,
filters: normalizedFilters,
enabled: isFilterInitialized,
});
const fetchNextPage = useCallback(async () => {
setIsFetching(true);
const res = await getSurveysAction({
environmentId,
limit: surveysLimit,
offset: surveys.length,
filterCriteria: filters,
});
if (res?.data) {
if (res.data.length === 0 || res.data.length < surveysLimit) {
setHasMore(false);
} else {
setHasMore(true);
}
const deleteSurveyMutation = useDeleteSurvey({ queryKey });
setSurveys([...surveys, ...res.data]);
setIsFetching(false);
}
}, [environmentId, surveys, surveysLimit, filters]);
const hasAppliedFilters = hasActiveSurveyFilters(normalizedFilters);
const showInitialLoading = !isFilterInitialized || (isLoading && surveys.length === 0);
const showTemplateEmptyState = !isError && totalCount === 0 && !hasAppliedFilters && !isReadOnly;
const showReadOnlyEmptyState = !isError && totalCount === 0 && !hasAppliedFilters && isReadOnly;
const handleDeleteSurvey = async (surveyId: string) => {
const newSurveys = surveys.filter((survey) => survey.id !== surveyId);
setSurveys(newSurveys);
if (newSurveys.length === 0) {
setIsFetching(true);
router.refresh();
}
await deleteSurveyMutation.mutateAsync({ surveyId });
};
const triggerRefresh = useCallback(() => {
setRefreshTrigger((prev) => !prev);
}, []);
const createSurveyButton = (
<Button size="sm" asChild>
<Link href={`/environments/${environment.id}/surveys/templates`}>
{t("environments.surveys.new_survey")}
<PlusIcon />
</Link>
</Button>
);
return (
<div className="space-y-6">
<SurveyFilters
surveyFilters={surveyFilters}
setSurveyFilters={setSurveyFilters}
currentProjectChannel={currentProjectChannel}
/>
{surveys.length > 0 ? (
<div>
<div className="flex-col space-y-3" ref={parent}>
<div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 pr-8 text-sm text-slate-800">
<div className="col-span-2 place-self-start">{t("common.name")}</div>
<div className="col-span-1">{t("common.status")}</div>
<div className="col-span-1">{t("common.responses")}</div>
<div className="col-span-1">{t("common.type")}</div>
<div className="col-span-1">{t("common.created_at")}</div>
<div className="col-span-1">{t("common.updated_at")}</div>
<div className="col-span-1">{t("common.created_by")}</div>
</div>
{surveys.map((survey) => {
return (
<SurveyCard
key={survey.id}
survey={survey}
environmentId={environmentId}
isReadOnly={isReadOnly}
publicDomain={publicDomain}
deleteSurvey={handleDeleteSurvey}
locale={locale}
onSurveysCopied={triggerRefresh}
/>
);
})}
if (showInitialLoading) {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.surveys")} />
<div className="flex items-center justify-between">
<div className="flex h-9 animate-pulse gap-2">
<div className="w-48 rounded-md bg-slate-300"></div>
{[1, 2, 3].map((i) => (
<div key={i} className="w-24 rounded-md bg-slate-300"></div>
))}
</div>
<div className="flex h-9 animate-pulse gap-2">
<div className="w-36 rounded-md bg-slate-300"></div>
</div>
</div>
<SurveyLoading />
</PageContentWrapper>
);
}
{hasMore && (
<div className="flex justify-center py-5">
<Button onClick={fetchNextPage} variant="secondary" size="sm" loading={isFetching}>
{t("common.load_more")}
</Button>
</div>
)}
</div>
) : (
<div className="flex h-full w-full">
{isFetching ? (
<SurveyLoading />
) : (
<div className="flex w-full flex-col items-center justify-center text-slate-600">
<span className="h-24 w-24 p-4 text-center text-5xl">🕵</span>
{t("common.no_surveys_found")}
</div>
)}
</div>
)}
if (showTemplateEmptyState) {
return (
<TemplateContainerWithPreview
userId={userId}
environment={environment}
project={project}
isTemplatePage={false}
publicDomain={publicDomain}
/>
);
}
if (showReadOnlyEmptyState) {
return (
<PageContentWrapper>
<h1 className="px-6 text-3xl font-extrabold text-slate-700">
{t("environments.surveys.no_surveys_created_yet")}
</h1>
<h2 className="px-6 text-lg font-medium text-slate-500">
{t("environments.surveys.read_only_user_not_allowed_to_create_survey_warning")}
</h2>
</PageContentWrapper>
);
}
let surveyContent = (
<div className="flex h-full w-full">
<div className="flex w-full flex-col items-center justify-center text-slate-600">
<span className="h-24 w-24 p-4 text-center text-5xl">🕵</span>
{t("common.no_surveys_found")}
</div>
</div>
);
if (isError && surveys.length === 0) {
surveyContent = (
<div className="flex w-full flex-col items-center justify-center gap-4 py-16 text-slate-600">
<p>{getV3ApiErrorMessage(error, t("common.something_went_wrong_please_try_again"))}</p>
<Button variant="secondary" size="sm" onClick={() => refetch()}>
{t("common.try_again")}
</Button>
</div>
);
} else if (surveys.length > 0) {
surveyContent = (
<div>
<div className="flex-col space-y-3" ref={parent}>
<div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 pr-8 text-sm text-slate-800">
<div className="col-span-2 place-self-start">{t("common.name")}</div>
<div className="col-span-1">{t("common.status")}</div>
<div className="col-span-1">{t("common.responses")}</div>
<div className="col-span-1">{t("common.type")}</div>
<div className="col-span-1">{t("common.created_at")}</div>
<div className="col-span-1">{t("common.updated_at")}</div>
<div className="col-span-1">{t("common.created_by")}</div>
</div>
{surveys.map((survey) => (
<SurveyCard
key={survey.id}
survey={survey}
environmentId={environment.id}
isReadOnly={isReadOnly}
deleteSurvey={handleDeleteSurvey}
publicDomain={publicDomain}
locale={locale}
/>
))}
</div>
{hasNextPage && (
<div className="flex justify-center py-5">
<Button
onClick={() => fetchNextPage()}
variant="secondary"
size="sm"
loading={isFetchingNextPage}>
{t("common.load_more")}
</Button>
</div>
)}
</div>
);
}
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : createSurveyButton} />
<div className="space-y-6">
<SurveyFilters
surveyFilters={normalizedFilters}
setSurveyFilters={setSurveyFilters}
currentProjectChannel={currentProjectChannel}
/>
{surveyContent}
</div>
</PageContentWrapper>
);
};
@@ -0,0 +1,163 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, createElement } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { surveyKeys } from "@/modules/survey/list/lib/query";
import { TSurveyListPage } from "@/modules/survey/list/lib/v3-surveys-client";
import { useDeleteSurvey } from "./use-delete-survey";
function createWrapper(queryClient: QueryClient) {
const Wrapper = ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
Wrapper.displayName = "UseDeleteSurveyTestWrapper";
return Wrapper;
}
function createQueryData(): { pages: TSurveyListPage[]; pageParams: (string | null)[] } {
return {
pages: [
{
data: [
{
id: "survey_1",
name: "Survey 1",
workspaceId: "env_1",
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "Alice" },
singleUse: null,
},
],
meta: {
limit: 20,
nextCursor: null,
totalCount: 1,
},
},
],
pageParams: [null],
};
}
describe("useDeleteSurvey", () => {
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("optimistically removes a survey and invalidates list queries on success", async () => {
let resolveFetch: ((value: Response) => void) | undefined;
const fetchPromise = new Promise<Response>((resolve) => {
resolveFetch = resolve;
});
vi.mocked(global.fetch).mockReturnValue(fetchPromise as Promise<Response>);
const queryClient = new QueryClient({
defaultOptions: {
mutations: { retry: false },
queries: { retry: false },
},
});
const queryKey = surveyKeys.list({
workspaceId: "env_1",
limit: 20,
filters: {
name: "",
status: [],
type: [],
sortBy: "relevance",
},
});
queryClient.setQueryData(queryKey, createQueryData());
const invalidateQueriesSpy = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(() => useDeleteSurvey({ queryKey }), {
wrapper: createWrapper(queryClient),
});
result.current.mutate({ surveyId: "survey_1" });
await waitFor(() =>
expect(queryClient.getQueryData<{ pages: TSurveyListPage[] }>(queryKey)?.pages[0]?.data).toEqual([])
);
expect(queryClient.getQueryData<{ pages: TSurveyListPage[] }>(queryKey)?.pages[0]?.meta.totalCount).toBe(
0
);
resolveFetch?.(
new Response(JSON.stringify({ data: { id: "survey_1" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: surveyKeys.lists() });
});
test("rolls the cache back when delete fails", async () => {
vi.mocked(global.fetch).mockResolvedValue(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
requestId: "req_1",
}),
{
status: 403,
headers: { "Content-Type": "application/problem+json" },
}
)
);
const queryClient = new QueryClient({
defaultOptions: {
mutations: { retry: false },
queries: { retry: false },
},
});
const queryKey = surveyKeys.list({
workspaceId: "env_1",
limit: 20,
filters: {
name: "",
status: [],
type: [],
sortBy: "relevance",
},
});
queryClient.setQueryData(queryKey, createQueryData());
const { result } = renderHook(() => useDeleteSurvey({ queryKey }), {
wrapper: createWrapper(queryClient),
});
await expect(
act(async () => {
await result.current.mutateAsync({ surveyId: "survey_1" });
})
).rejects.toThrow("You are not authorized to access this resource");
expect(queryClient.getQueryData<{ pages: TSurveyListPage[] }>(queryKey)?.pages[0]?.data).toHaveLength(1);
expect(queryClient.getQueryData<{ pages: TSurveyListPage[] }>(queryKey)?.pages[0]?.meta.totalCount).toBe(
1
);
});
});
@@ -0,0 +1,34 @@
"use client";
import { InfiniteData, useMutation, useQueryClient } from "@tanstack/react-query";
import { removeSurveyFromInfiniteData, surveyKeys } from "@/modules/survey/list/lib/query";
import { TSurveyListPage, deleteSurvey } from "@/modules/survey/list/lib/v3-surveys-client";
export const useDeleteSurvey = ({ queryKey }: { queryKey: ReturnType<typeof surveyKeys.list> }) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ surveyId }: { surveyId: string }) => deleteSurvey(surveyId),
onMutate: async ({ surveyId }) => {
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData<InfiniteData<TSurveyListPage>>(queryKey);
queryClient.setQueryData<InfiniteData<TSurveyListPage> | undefined>(queryKey, (currentData) =>
removeSurveyFromInfiniteData(currentData, surveyId)
);
return {
previousData,
};
},
onError: (_error, _variables, context) => {
if (context?.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: surveyKeys.lists() });
},
});
};
@@ -0,0 +1,238 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, createElement } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { useSurveys } from "./use-surveys";
function createWrapper(queryClient: QueryClient) {
const Wrapper = ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
Wrapper.displayName = "UseSurveysTestWrapper";
return Wrapper;
}
describe("useSurveys", () => {
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("fetches the initial page and the next cursor page", async () => {
const fetchMock = vi.mocked(global.fetch);
fetchMock
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: [
{
id: "survey_1",
name: "Survey 1",
workspaceId: "env_1",
type: "link",
status: "draft",
createdAt: "2026-04-15T10:00:00.000Z",
updatedAt: "2026-04-15T10:00:00.000Z",
responseCount: 0,
creator: { name: "Alice" },
singleUse: null,
},
],
meta: {
limit: 20,
nextCursor: "cursor_1",
totalCount: 2,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: [
{
id: "survey_2",
name: "Survey 2",
workspaceId: "env_1",
type: "app",
status: "completed",
createdAt: "2026-04-15T11:00:00.000Z",
updatedAt: "2026-04-15T11:00:00.000Z",
responseCount: 2,
creator: { name: "Bob" },
singleUse: null,
},
],
meta: {
limit: 20,
nextCursor: null,
totalCount: 2,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const { result } = renderHook(
() =>
useSurveys({
workspaceId: "env_1",
limit: 20,
filters: {
name: "",
status: [],
type: [],
sortBy: "relevance",
},
}),
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.surveys).toHaveLength(1);
expect(result.current.totalCount).toBe(2);
expect(result.current.hasNextPage).toBe(true);
await act(async () => {
await result.current.fetchNextPage();
});
await waitFor(() => expect(result.current.surveys).toHaveLength(2));
expect(result.current.hasNextPage).toBe(false);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"/api/v3/surveys?workspaceId=env_1&limit=20&sortBy=relevance&cursor=cursor_1",
expect.objectContaining({
method: "GET",
cache: "no-store",
})
);
});
test("keeps the previous page data while refetching for new filters", async () => {
let resolveNextResponse: ((value: Response) => void) | undefined;
const nextResponsePromise = new Promise<Response>((resolve) => {
resolveNextResponse = resolve;
});
const fetchMock = vi.mocked(global.fetch);
fetchMock
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: [
{
id: "survey_1",
name: "Survey 1",
workspaceId: "env_1",
type: "link",
status: "draft",
createdAt: "2026-04-15T10:00:00.000Z",
updatedAt: "2026-04-15T10:00:00.000Z",
responseCount: 0,
creator: { name: "Alice" },
singleUse: null,
},
],
meta: {
limit: 20,
nextCursor: null,
totalCount: 1,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
)
.mockReturnValueOnce(nextResponsePromise as Promise<Response>);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const initialFilters: TSurveyOverviewFilters = {
name: "",
status: [],
type: [],
sortBy: "relevance",
};
const { result, rerender } = renderHook(
({ filters }) =>
useSurveys({
workspaceId: "env_1",
limit: 20,
filters,
}),
{
initialProps: { filters: initialFilters },
wrapper: createWrapper(queryClient),
}
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.surveys).toHaveLength(1);
rerender({
filters: {
...initialFilters,
name: "new query",
},
});
await waitFor(() => expect(result.current.isFetching).toBe(true));
expect(result.current.surveys).toHaveLength(1);
expect(result.current.surveys[0]?.name).toBe("Survey 1");
resolveNextResponse?.(
new Response(
JSON.stringify({
data: [
{
id: "survey_2",
name: "Survey 2",
workspaceId: "env_1",
type: "link",
status: "paused",
createdAt: "2026-04-15T11:00:00.000Z",
updatedAt: "2026-04-15T11:00:00.000Z",
responseCount: 4,
creator: { name: "Bob" },
singleUse: null,
},
],
meta: {
limit: 20,
nextCursor: null,
totalCount: 1,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);
await waitFor(() => expect(result.current.surveys[0]?.name).toBe("Survey 2"));
});
});
@@ -0,0 +1,50 @@
"use client";
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
import { flattenSurveyPages, surveyKeys } from "@/modules/survey/list/lib/query";
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { listSurveys } from "../lib/v3-surveys-client";
export const useSurveys = ({
workspaceId,
limit,
filters,
enabled = true,
}: {
workspaceId: string;
limit: number;
filters: TSurveyOverviewFilters;
enabled?: boolean;
}) => {
const queryKey = surveyKeys.list({
workspaceId,
limit,
filters,
});
const query = useInfiniteQuery({
queryKey,
initialPageParam: null as string | null,
enabled,
placeholderData: keepPreviousData,
queryFn: ({ pageParam, signal }) =>
listSurveys({
workspaceId,
limit,
cursor: pageParam,
filters,
signal,
}),
getNextPageParam: (lastPage) => lastPage.meta.nextCursor ?? undefined,
});
const surveys = flattenSurveyPages(query.data);
const totalCount = query.data?.pages[0]?.meta.totalCount ?? 0;
return {
...query,
queryKey,
surveys,
totalCount,
};
};
@@ -1,8 +1,7 @@
import { TSurveyFilters } from "@formbricks/types/surveys/types";
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
export const initialFilters: TSurveyFilters = {
export const initialFilters: TSurveyOverviewFilters = {
name: "",
createdBy: [],
status: [],
type: [],
sortBy: "relevance",
@@ -0,0 +1,66 @@
import type { InfiniteData } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import { flattenSurveyPages, removeSurveyFromInfiniteData } from "./query";
import { TSurveyListPage } from "./v3-surveys-client";
const surveyA = {
id: "survey_a",
name: "Survey A",
workspaceId: "env_1",
type: "link" as const,
status: "draft" as const,
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "Alice" },
singleUse: null,
};
const surveyB = {
...surveyA,
id: "survey_b",
name: "Survey B",
};
const baseData: InfiniteData<TSurveyListPage> = {
pages: [
{
data: [surveyA],
meta: {
limit: 20,
nextCursor: "cursor-1",
totalCount: 2,
},
},
{
data: [surveyB],
meta: {
limit: 20,
nextCursor: null,
totalCount: 2,
},
},
],
pageParams: [null, "cursor-1"],
};
describe("flattenSurveyPages", () => {
test("flattens every fetched page", () => {
expect(flattenSurveyPages(baseData)).toEqual([surveyA, surveyB]);
});
});
describe("removeSurveyFromInfiniteData", () => {
test("removes the survey from cached pages and decrements each page total", () => {
const nextData = removeSurveyFromInfiniteData(baseData, "survey_a");
expect(nextData?.pages[0]?.data).toEqual([]);
expect(nextData?.pages[1]?.data).toEqual([surveyB]);
expect(nextData?.pages[0]?.meta.totalCount).toBe(1);
expect(nextData?.pages[1]?.meta.totalCount).toBe(1);
});
test("returns the original cache when the survey is not present", () => {
expect(removeSurveyFromInfiniteData(baseData, "missing_survey")).toBe(baseData);
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { InfiniteData } from "@tanstack/react-query";
import { TSurveyListItem, TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { TSurveyListPage } from "./v3-surveys-client";
type TSurveyListKeyInput = {
workspaceId: string;
limit: number;
filters: TSurveyOverviewFilters;
};
export const surveyKeys = {
all: ["surveys"] as const,
lists: () => [...surveyKeys.all, "list"] as const,
list: (input: TSurveyListKeyInput) => [...surveyKeys.lists(), input] as const,
};
export function flattenSurveyPages(data?: InfiniteData<TSurveyListPage>): TSurveyListItem[] {
return data?.pages.flatMap((page) => page.data) ?? [];
}
export function removeSurveyFromInfiniteData(
data: InfiniteData<TSurveyListPage> | undefined,
surveyId: string
): InfiniteData<TSurveyListPage> | undefined {
if (!data) {
return data;
}
let surveyWasRemoved = false;
const pages = data.pages.map((page) => {
const nextData = page.data.filter((survey) => survey.id !== surveyId);
if (nextData.length !== page.data.length) {
surveyWasRemoved = true;
}
return {
...page,
data: nextData,
};
});
if (!surveyWasRemoved) {
return data;
}
return {
...data,
pages: pages.map((page) => ({
...page,
meta: {
...page.meta,
totalCount: Math.max(0, page.meta.totalCount - 1),
},
})),
};
}
@@ -17,7 +17,6 @@ import { TProjectWithLanguages, TSurvey } from "../types/surveys";
// Import the module to be tested
import {
copySurveyToOtherEnvironment,
deleteSurvey,
getSurvey,
getSurveyCount,
getSurveys,
@@ -420,57 +419,6 @@ describe("getSurveysSortedByRelevance", () => {
});
});
describe("deleteSurvey", () => {
beforeEach(() => {
resetMocks();
});
const mockDeletedSurveyData = {
id: surveyId,
environmentId,
segment: null,
type: "web" as any,
triggers: [{ actionClass: { id: "action_1" } }],
};
test("should delete a survey and revalidate caches (no private segment)", async () => {
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyData as any);
const result = await deleteSurvey(surveyId);
expect(result).toBe(true);
expect(prisma.survey.delete).toHaveBeenCalledWith({
where: { id: surveyId },
select: expect.objectContaining({ id: true, environmentId: true, segment: expect.anything() }),
});
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should revalidate segment cache for non-private segment if segment exists", async () => {
const surveyWithPublicSegment = {
...mockDeletedSurveyData,
segment: { id: "segment_public_1", isPrivate: false },
};
vi.mocked(prisma.survey.delete).mockResolvedValue(surveyWithPublicSegment as any);
await deleteSurvey(surveyId);
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should throw DatabaseError on Prisma error", async () => {
const prismaError = makePrismaKnownError();
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error deleting survey");
});
test("should rethrow unknown error", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.survey.delete).mockRejectedValue(unknownError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(unknownError);
});
});
const mockExistingSurveyDetails = {
name: "Original Survey",
type: "web" as any,
@@ -145,53 +145,6 @@ export const getSurvey = reactCache(async (surveyId: string): Promise<TSurvey |
return mapSurveyRowToSurvey(surveyPrisma);
});
export const deleteSurvey = async (surveyId: string): Promise<boolean> => {
try {
const deletedSurvey = await prisma.survey.delete({
where: {
id: surveyId,
},
select: {
id: true,
environmentId: true,
segment: {
select: {
id: true,
isPrivate: true,
},
},
type: true,
triggers: {
select: {
actionClass: {
select: {
id: true,
},
},
},
},
},
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
await prisma.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
}
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error deleting survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
const getExistingSurvey = async (surveyId: string) => {
return await prisma.survey.findUnique({
where: {
+90 -60
View File
@@ -1,68 +1,98 @@
import { describe, expect, test } from "vitest";
import type { TSurveyFilters } from "@formbricks/types/surveys/types";
import { getFormattedFilters } from "./utils";
import { hasActiveSurveyFilters, normalizeSurveyFilters, parseStoredSurveyFilters } from "./utils";
describe("getFormattedFilters", () => {
test("returns empty object when no filters provided", () => {
const result = getFormattedFilters({} as TSurveyFilters, "user1");
expect(result).toEqual({});
describe("normalizeSurveyFilters", () => {
test("returns the normalized default filters when input is empty", () => {
expect(normalizeSurveyFilters(undefined)).toEqual({
name: "",
status: [],
type: [],
sortBy: "relevance",
});
});
test("includes name filter", () => {
const result = getFormattedFilters({ name: "surveyName" } as TSurveyFilters, "user1");
expect(result).toEqual({ name: "surveyName" });
});
test("includes status filter when array is non-empty", () => {
const result = getFormattedFilters({ status: ["active", "inactive"] } as any, "user1");
expect(result).toEqual({ status: ["active", "inactive"] });
});
test("ignores status filter when empty array", () => {
const result = getFormattedFilters({ status: [] } as any, "user1");
expect(result).toEqual({});
});
test("includes type filter when array is non-empty", () => {
const result = getFormattedFilters({ type: ["typeA"] } as any, "user1");
expect(result).toEqual({ type: ["typeA"] });
});
test("ignores type filter when empty array", () => {
const result = getFormattedFilters({ type: [] } as any, "user1");
expect(result).toEqual({});
});
test("includes createdBy filter when array is non-empty", () => {
const result = getFormattedFilters({ createdBy: ["ownerA", "ownerB"] } as any, "user1");
expect(result).toEqual({ createdBy: { userId: "user1", value: ["ownerA", "ownerB"] } });
});
test("ignores createdBy filter when empty array", () => {
const result = getFormattedFilters({ createdBy: [] } as any, "user1");
expect(result).toEqual({});
});
test("includes sortBy filter", () => {
const result = getFormattedFilters({ sortBy: "date" } as any, "user1");
expect(result).toEqual({ sortBy: "date" });
});
test("combines multiple filters", () => {
const input: TSurveyFilters = {
name: "nameVal",
status: ["draft"],
type: ["link", "app"],
createdBy: ["you"],
sortBy: "name",
};
const result = getFormattedFilters(input, "userX");
expect(result).toEqual({
name: "nameVal",
status: ["draft"],
type: ["link", "app"],
createdBy: { userId: "userX", value: ["you"] },
test("trims names, removes unsupported fields, and sorts filter arrays", () => {
expect(
normalizeSurveyFilters({
name: " Customer feedback ",
createdBy: ["you"],
status: ["paused", "draft", "paused"],
type: ["link", "app", "link"],
sortBy: "name",
} as any)
).toEqual({
name: "Customer feedback",
status: ["draft", "paused"],
type: ["app", "link"],
sortBy: "name",
});
});
test("drops type filters when the project channel is link-only", () => {
expect(
normalizeSurveyFilters(
{
name: "",
status: [],
type: ["app", "link"],
sortBy: "updatedAt",
},
"link"
)
).toEqual({
name: "",
status: [],
type: [],
sortBy: "updatedAt",
});
});
});
describe("parseStoredSurveyFilters", () => {
test("returns null for invalid JSON", () => {
expect(parseStoredSurveyFilters("{")).toBeNull();
});
test("sanitizes legacy stored filters", () => {
expect(
parseStoredSurveyFilters(
JSON.stringify({
name: " NPS ",
createdBy: ["you"],
status: ["completed", "draft"],
type: ["link"],
sortBy: "createdAt",
})
)
).toEqual({
name: "NPS",
status: ["completed", "draft"],
type: ["link"],
sortBy: "createdAt",
});
});
});
describe("hasActiveSurveyFilters", () => {
test("ignores sort-only changes", () => {
expect(
hasActiveSurveyFilters({
name: "",
status: [],
type: [],
sortBy: "createdAt",
})
).toBe(false);
});
test("detects active filters", () => {
expect(
hasActiveSurveyFilters({
name: "CSAT",
status: [],
type: [],
sortBy: "relevance",
})
).toBe(true);
});
});
+67 -20
View File
@@ -1,30 +1,77 @@
import { TSurveyFilterCriteria, TSurveyFilters } from "@formbricks/types/surveys/types";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { initialFilters } from "@/modules/survey/list/lib/constants";
import {
TSurveyOverviewFilters,
TSurveyOverviewSort,
TSurveyOverviewType,
} from "@/modules/survey/list/types/survey-overview";
export const getFormattedFilters = (surveyFilters: TSurveyFilters, userId: string): TSurveyFilterCriteria => {
const filters: TSurveyFilterCriteria = {};
const allowedStatus = new Set(["draft", "inProgress", "paused", "completed"] as const);
const allowedType = new Set(["app", "link"] as const);
const allowedSort = new Set(["createdAt", "updatedAt", "name", "relevance"] as const);
const compareNormalizedFilterValues = (left: string, right: string) => left.localeCompare(right);
if (surveyFilters.name) {
filters.name = surveyFilters.name;
function getNormalizedStatus(value: unknown): TSurveyOverviewFilters["status"] {
if (!Array.isArray(value)) {
return [];
}
if (surveyFilters.status && surveyFilters.status.length) {
filters.status = surveyFilters.status;
return [
...new Set(
value.filter((status): status is TSurveyOverviewFilters["status"][number] =>
allowedStatus.has(status as never)
)
),
].sort(compareNormalizedFilterValues);
}
function getNormalizedType(
value: unknown,
currentProjectChannel?: TProjectConfigChannel
): TSurveyOverviewType[] {
if (currentProjectChannel === "link" || !Array.isArray(value)) {
return [];
}
if (surveyFilters.type && surveyFilters.type.length) {
filters.type = surveyFilters.type;
return [
...new Set(value.filter((type): type is TSurveyOverviewType => allowedType.has(type as never))),
].sort(compareNormalizedFilterValues);
}
function getNormalizedSort(value: unknown): TSurveyOverviewSort {
return allowedSort.has(value as never) ? (value as TSurveyOverviewSort) : initialFilters.sortBy;
}
export function normalizeSurveyFilters(
filters: Partial<TSurveyOverviewFilters> | null | undefined,
currentProjectChannel?: TProjectConfigChannel
): TSurveyOverviewFilters {
return {
name: typeof filters?.name === "string" ? filters.name.trim() : initialFilters.name,
status: getNormalizedStatus(filters?.status),
type: getNormalizedType(filters?.type, currentProjectChannel),
sortBy: getNormalizedSort(filters?.sortBy),
};
}
export function parseStoredSurveyFilters(
storedValue: string | null,
currentProjectChannel?: TProjectConfigChannel
): TSurveyOverviewFilters | null {
if (!storedValue) {
return null;
}
if (surveyFilters.createdBy && surveyFilters.createdBy.length) {
filters.createdBy = {
userId: userId,
value: surveyFilters.createdBy,
};
try {
return normalizeSurveyFilters(
JSON.parse(storedValue) as Partial<TSurveyOverviewFilters>,
currentProjectChannel
);
} catch {
return null;
}
}
if (surveyFilters.sortBy) {
filters.sortBy = surveyFilters.sortBy;
}
return filters;
};
export function hasActiveSurveyFilters(filters: TSurveyOverviewFilters): boolean {
return Boolean(filters.name) || filters.status.length > 0 || filters.type.length > 0;
}
@@ -0,0 +1,22 @@
import { describe, expect, test } from "vitest";
import { buildSurveyListSearchParams } from "./v3-surveys-client";
describe("buildSurveyListSearchParams", () => {
test("emits only supported v3 params using normalized filter values", () => {
const searchParams = buildSurveyListSearchParams({
workspaceId: "env_1",
limit: 20,
cursor: "cursor_1",
filters: {
name: " Product feedback ",
status: ["paused", "draft"],
type: ["link", "app"],
sortBy: "relevance",
},
});
expect(searchParams.toString()).toBe(
"workspaceId=env_1&limit=20&sortBy=relevance&cursor=cursor_1&filter%5Bname%5D%5Bcontains%5D=Product+feedback&filter%5Bstatus%5D%5Bin%5D=draft&filter%5Bstatus%5D%5Bin%5D=paused&filter%5Btype%5D%5Bin%5D=app&filter%5Btype%5D%5Bin%5D=link"
);
});
});
@@ -0,0 +1,121 @@
import { parseV3ApiError } from "@/modules/api/lib/v3-client";
import { normalizeSurveyFilters } from "@/modules/survey/list/lib/utils";
import { TSurveyListItem, TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
type TV3SurveyListItemResponse = Omit<TSurveyListItem, "createdAt" | "updatedAt"> & {
createdAt: string;
updatedAt: string;
};
type TV3SurveyListResponse = {
data: TV3SurveyListItemResponse[];
meta: TSurveyListPage["meta"];
};
type TV3DeleteSurveyResponse = {
data: {
id: string;
};
};
export type TSurveyListPage = {
data: TSurveyListItem[];
meta: {
limit: number;
nextCursor: string | null;
totalCount: number;
};
};
function mapSurveyListItem(survey: TV3SurveyListItemResponse): TSurveyListItem {
return {
...survey,
createdAt: new Date(survey.createdAt),
updatedAt: new Date(survey.updatedAt),
};
}
export function buildSurveyListSearchParams({
workspaceId,
limit,
cursor,
filters,
}: {
workspaceId: string;
limit: number;
cursor?: string | null;
filters: TSurveyOverviewFilters;
}): URLSearchParams {
const normalizedFilters = normalizeSurveyFilters(filters);
const searchParams = new URLSearchParams();
searchParams.set("workspaceId", workspaceId);
searchParams.set("limit", String(limit));
searchParams.set("sortBy", normalizedFilters.sortBy);
if (cursor) {
searchParams.set("cursor", cursor);
}
if (normalizedFilters.name) {
searchParams.set("filter[name][contains]", normalizedFilters.name);
}
normalizedFilters.status.forEach((status) => {
searchParams.append("filter[status][in]", status);
});
normalizedFilters.type.forEach((type) => {
searchParams.append("filter[type][in]", type);
});
return searchParams;
}
export async function listSurveys({
workspaceId,
limit,
cursor,
filters,
signal,
}: {
workspaceId: string;
limit: number;
cursor?: string | null;
filters: TSurveyOverviewFilters;
signal?: AbortSignal;
}): Promise<TSurveyListPage> {
const response = await fetch(
`/api/v3/surveys?${buildSurveyListSearchParams({ workspaceId, limit, cursor, filters }).toString()}`,
{
method: "GET",
cache: "no-store",
signal,
}
);
if (!response.ok) {
throw await parseV3ApiError(response);
}
const body = (await response.json()) as TV3SurveyListResponse;
return {
data: body.data.map(mapSurveyListItem),
meta: body.meta,
};
}
export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
const response = await fetch(`/api/v3/surveys/${surveyId}`, {
method: "DELETE",
cache: "no-store",
});
if (!response.ok) {
throw await parseV3ApiError(response);
}
const body = (await response.json()) as TV3DeleteSurveyResponse;
return body.data;
}
+12 -60
View File
@@ -1,6 +1,4 @@
import { PlusIcon } from "lucide-react";
import { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD, SURVEYS_PER_PAGE } from "@/lib/constants";
@@ -11,11 +9,6 @@ import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
import { SurveysList } from "@/modules/survey/list/components/survey-list";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { TemplateContainerWithPreview } from "@/modules/survey/templates/components/template-container";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
export const metadata: Metadata = {
title: "Your Surveys",
@@ -44,65 +37,24 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
}
const surveyCount = await getSurveyCount(params.environmentId);
const currentProjectChannel = project.config.channel ?? null;
const locale = (await getUserLocale(session.user.id)) ?? DEFAULT_LOCALE;
const createSurveyButton = (
<Button size="sm" asChild>
<Link href={`/environments/${environment.id}/surveys/templates`}>
{t("environments.surveys.new_survey")}
<PlusIcon />
</Link>
</Button>
);
const projectWithRequiredProps = {
...project,
brandColor: project.styling?.brandColor?.light ?? null,
highlightBorderColor: null,
};
if (surveyCount === 0)
return (
<TemplateContainerWithPreview
userId={session.user.id}
environment={environment}
project={projectWithRequiredProps}
isTemplatePage={false}
publicDomain={publicDomain}
/>
);
let content;
if (surveyCount > 0) {
content = (
<>
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : createSurveyButton} />
<SurveysList
environmentId={environment.id}
isReadOnly={isReadOnly}
publicDomain={publicDomain}
userId={session.user.id}
surveysPerPage={SURVEYS_PER_PAGE}
currentProjectChannel={currentProjectChannel}
locale={locale}
/>
</>
);
} else if (isReadOnly) {
content = (
<>
<h1 className="px-6 text-3xl font-extrabold text-slate-700">
{t("environments.surveys.no_surveys_created_yet")}
</h1>
<h2 className="px-6 text-lg font-medium text-slate-500">
{t("environments.surveys.read_only_user_not_allowed_to_create_survey_warning")}
</h2>
</>
);
}
return <PageContentWrapper>{content}</PageContentWrapper>;
return (
<SurveysList
environment={environment}
project={projectWithRequiredProps}
isReadOnly={isReadOnly}
publicDomain={publicDomain}
userId={session.user.id}
surveysPerPage={SURVEYS_PER_PAGE}
currentProjectChannel={currentProjectChannel}
locale={locale}
/>
);
};
@@ -0,0 +1,39 @@
import { z } from "zod";
import { ZSurveyStatus } from "@formbricks/types/surveys/types";
export const ZSurveyOverviewType = z.enum(["link", "app"]);
export const ZSurveyOverviewSort = z.enum(["createdAt", "updatedAt", "name", "relevance"]);
export const ZSurveyOverviewFilters = z.object({
name: z.string(),
status: z.array(ZSurveyStatus),
type: z.array(ZSurveyOverviewType),
sortBy: ZSurveyOverviewSort,
});
export const ZSurveyListItem = z.object({
id: z.string(),
name: z.string(),
workspaceId: z.string(),
type: z.enum(["link", "app", "website", "web"]),
status: ZSurveyStatus,
createdAt: z.date(),
updatedAt: z.date(),
responseCount: z.number(),
creator: z
.object({
name: z.string(),
})
.nullable(),
singleUse: z
.object({
enabled: z.boolean(),
isEncrypted: z.boolean(),
})
.nullable(),
});
export type TSurveyOverviewType = z.infer<typeof ZSurveyOverviewType>;
export type TSurveyOverviewStatus = z.infer<typeof ZSurveyStatus>;
export type TSurveyOverviewSort = z.infer<typeof ZSurveyOverviewSort>;
export type TSurveyOverviewFilters = z.infer<typeof ZSurveyOverviewFilters>;
export type TSurveyListItem = z.infer<typeof ZSurveyListItem>;
@@ -26,17 +26,6 @@ export const ZSurvey = z.object({
export type TSurvey = z.infer<typeof ZSurvey>;
export const ZSurveyCopyFormValidation = z.object({
projects: z.array(
z.object({
project: z.string(),
environments: z.array(z.string()),
})
),
});
export type TSurveyCopyFormData = z.infer<typeof ZSurveyCopyFormValidation>;
export interface TProjectWithLanguages extends Pick<Project, "id"> {
languages: Pick<Language, "code" | "alias">[];
}