mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-24 11:39:31 -05:00
feat: migrate survey overview to v3 APIs (#7741)
This commit is contained in:
committed by
GitHub
parent
b1cee91ad9
commit
6fcb6863bd
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user