Compare commits

..

3 Commits

Author SHA1 Message Date
Dhruwang 8374eea770 fix: i18n lock 2026-04-17 15:15:06 +05:30
Dhruwang d3ccc623e0 Merge branch 'main' of https://github.com/formbricks/formbricks into fix/issue-7543-trial-conversion-template 2026-04-17 15:13:03 +05:30
Niels Kaspers 8619916682 fix: fix duplicate block and misleading subheader in trial conversion template
- Block 3 now uses its own question ("What did you expect to do?")
  instead of duplicating Block 2's question
- Updated Block 5 subheader from "Please select one of the following
  options" to "Please describe below" to match the open text input type
- Added translations for the new Block 3 question across all locales

Fixes #7543
2026-03-23 09:49:08 +02:00
126 changed files with 1709 additions and 4877 deletions
+1 -1
View File
@@ -23,7 +23,7 @@
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.2",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.17"
}
}
@@ -409,22 +409,16 @@ export const MainNavigation = ({
: `/environments/${environment.id}/surveys/`;
const handleProjectChange = (projectId: string) => {
const targetPath =
projectId === project.id ? `/environments/${environment.id}/surveys` : `/workspaces/${projectId}/`;
if (projectId === project.id) return;
startTransition(() => {
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
router.push(`/workspaces/${projectId}/`);
});
};
const handleOrganizationChange = (organizationId: string) => {
const targetPath =
organizationId === organization.id
? `/environments/${environment.id}/settings/general`
: `/organizations/${organizationId}/`;
if (organizationId === organization.id) return;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
router.push(targetPath);
router.push(`/organizations/${organizationId}/`);
});
};
@@ -114,12 +114,8 @@ export const OrganizationBreadcrumb = ({
}
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === currentOrganizationId) return;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
if (organizationId === currentOrganizationId && currentEnvironmentId) {
router.push(`/environments/${currentEnvironmentId}/settings/general`);
return;
}
router.push(`/organizations/${organizationId}/`);
});
};
@@ -152,13 +152,9 @@ export const ProjectBreadcrumb = ({
}
const handleProjectChange = (projectId: string) => {
const targetPath =
projectId === currentProjectId
? `/environments/${currentEnvironmentId}/surveys`
: `/workspaces/${projectId}/`;
if (projectId === currentProjectId) return;
startTransition(() => {
setIsProjectDropdownOpen(false);
router.push(targetPath);
router.push(`/workspaces/${projectId}/`);
});
};
@@ -1,8 +0,0 @@
import { type ReactNode } from "react";
import { SurveysQueryClientProvider } from "./query-client-provider";
const SurveysLayout = ({ children }: { children: ReactNode }) => {
return <SurveysQueryClientProvider>{children}</SurveysQueryClientProvider>;
};
export default SurveysLayout;
@@ -1,10 +0,0 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
export const SurveysQueryClientProvider = ({ children }: { children: ReactNode }) => {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
@@ -1,98 +0,0 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getResponseIdByDisplayId } from "./response";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((inputs: [unknown, unknown][]) =>
inputs.map((input: [unknown, unknown]) => input[0])
),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
findFirst: vi.fn(),
},
},
}));
describe("getResponseIdByDisplayId", () => {
const environmentId = "env1234567890123456789012";
const displayId = "display1234567890123456789";
beforeEach(() => {
vi.clearAllMocks();
});
test("returns the linked responseId when a response exists", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue({
response: {
id: "response123456789012345678",
},
} as any);
const result = await getResponseIdByDisplayId(environmentId, displayId);
expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)],
[displayId, expect.any(Object)]
);
expect(prisma.display.findFirst).toHaveBeenCalledWith({
where: {
id: displayId,
survey: {
environmentId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
expect(result).toEqual({ responseId: "response123456789012345678" });
});
test("returns null when the display exists but has no response", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue({
response: null,
} as any);
await expect(getResponseIdByDisplayId(environmentId, displayId)).resolves.toEqual({
responseId: null,
});
});
test("throws ResourceNotFoundError when the display does not exist in the environment", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue(null);
await expect(getResponseIdByDisplayId(environmentId, displayId)).rejects.toThrow(
new ResourceNotFoundError("Display", displayId)
);
});
test("throws ValidationError when input validation fails", async () => {
const validationError = new ValidationError("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(getResponseIdByDisplayId(environmentId, displayId)).rejects.toThrow(ValidationError);
expect(prisma.display.findFirst).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "test",
});
vi.mocked(prisma.display.findFirst).mockRejectedValue(prismaError);
await expect(getResponseIdByDisplayId(environmentId, displayId)).rejects.toThrow(DatabaseError);
});
});
@@ -1,44 +0,0 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getResponseIdByDisplayId = async (
environmentId: string,
displayId: string
): Promise<{ responseId: string | null }> => {
validateInputs([environmentId, ZId], [displayId, ZId]);
try {
const display = await prisma.display.findFirst({
where: {
id: displayId,
survey: {
environmentId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
if (!display) {
throw new ResourceNotFoundError("Display", displayId);
}
return {
responseId: display.response?.id ?? null,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -1,70 +0,0 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getResponseIdByDisplayId } from "./lib/response";
import { GET } from "./route";
vi.mock("@/app/lib/api/with-api-logging", async () => {
return {
withV1ApiWrapper:
({ handler }: { handler: any }) =>
async (req: NextRequest, props: any) => {
const result = await handler({ req, props });
return result.response;
},
};
});
vi.mock("./lib/response", () => ({
getResponseIdByDisplayId: vi.fn(),
}));
describe("GET /api/v1/client/[environmentId]/displays/[displayId]/response", () => {
const req = new NextRequest("http://localhost/api/v1/client/env/displays/display/response");
const props = {
params: Promise.resolve({
environmentId: "env1234567890123456789012",
displayId: "display1234567890123456789",
}),
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns the responseId when a linked response exists", async () => {
vi.mocked(getResponseIdByDisplayId).mockResolvedValue({ responseId: "response123456789012345678" });
const response = await GET(req, props);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: {
responseId: "response123456789012345678",
},
});
});
test("returns null when the display exists without a response", async () => {
vi.mocked(getResponseIdByDisplayId).mockResolvedValue({ responseId: null });
const response = await GET(req, props);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: {
responseId: null,
},
});
});
test("returns 404 when the display is missing for the environment", async () => {
vi.mocked(getResponseIdByDisplayId).mockRejectedValue(
new ResourceNotFoundError("Display", "display1234567890123456789")
);
const response = await GET(req, props);
expect(response.status).toBe(404);
});
});
@@ -1,40 +0,0 @@
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getResponseIdByDisplayId } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: THandlerParams<{ params: Promise<{ environmentId: string; displayId: string }> }>) => {
const params = await props.params;
try {
const response = await getResponseIdByDisplayId(params.environmentId, params.displayId);
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Display", params.displayId, true),
};
}
logger.error(
{ error, url: req.url, environmentId: params.environmentId, displayId: params.displayId },
"Error in GET /api/v1/client/[environmentId]/displays/[displayId]/response"
);
return {
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
};
}
},
});
@@ -1,19 +1,47 @@
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 } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey } from "./surveys";
const { mockDeleteSharedSurvey } = vi.hoisted(() => ({
mockDeleteSharedSurvey: vi.fn(),
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: mockDeleteSharedSurvey,
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
delete: vi.fn(),
},
segment: {
delete: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: 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: "clq5n7p1q0000m7z0h5p6g3r3",
environmentId,
type: "link",
segment: null,
triggers: [],
@@ -28,20 +56,66 @@ describe("deleteSurvey", () => {
vi.clearAllMocks();
});
test("delegates survey deletion to the shared service", async () => {
mockDeleteSharedSurvey.mockResolvedValue(mockDeletedSurveyLink);
test("should delete a link survey without a segment and revalidate caches", async () => {
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any);
const deletedSurvey = await deleteSurvey(surveyId);
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
expect(prisma.survey.delete).toHaveBeenCalledWith({
where: { id: surveyId },
include: {
segment: true,
triggers: { include: { actionClass: true } },
},
});
expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
});
test("rethrows shared delete service errors", async () => {
test("should handle PrismaClientKnownRequestError during survey deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should handle PrismaClientKnownRequestError during segment deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Foreign key constraint failed", {
code: "P2003",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyAppPrivateSegment as any);
vi.mocked(prisma.segment.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } });
});
test("should handle generic errors during deletion", async () => {
const genericError = new Error("Something went wrong");
mockDeleteSharedSurvey.mockRejectedValue(genericError);
vi.mocked(prisma.survey.delete).mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
expect(logger.error).not.toHaveBeenCalled();
expect(prisma.segment.delete).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.survey.delete).not.toHaveBeenCalled();
});
});
@@ -1,3 +1,43 @@
import { deleteSurvey as deleteSharedSurvey } from "@/modules/survey/lib/surveys";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const deleteSurvey = async (surveyId: string) => deleteSharedSurvey(surveyId);
export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, z.cuid2()]);
try {
const deletedSurvey = await prisma.survey.delete({
where: {
id: surveyId,
},
include: {
segment: true,
triggers: {
include: {
actionClass: true,
},
},
},
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
await prisma.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
}
return deletedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error({ error, surveyId }, "Error deleting survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -1,6 +1,5 @@
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
@@ -71,12 +70,6 @@ export const GET = withV1ApiWrapper({
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Survey", params.surveyId),
};
}
return {
response: handleErrorResponse(error),
};
-132
View File
@@ -9,22 +9,6 @@ const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: mockGetServerSession,
}));
@@ -41,14 +25,6 @@ vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
@@ -69,114 +45,6 @@ describe("withV3ApiWrapper", () => {
vi.clearAllMocks();
});
test("passes an audit log to the handler and queues success after the response", async () => {
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1", name: "Test", email: "t@example.com" },
expires: "2026-01-01",
});
const handler = vi.fn(async ({ auditLog }) => {
expect(auditLog).toEqual(
expect.objectContaining({
action: "deleted",
targetType: "survey",
userId: "user_1",
userType: "user",
status: "failure",
})
);
if (auditLog) {
auditLog.targetId = "survey_1";
auditLog.organizationId = "org_1";
auditLog.oldObject = { id: "survey_1" };
}
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys/survey_1", {
method: "DELETE",
headers: { "x-request-id": "req-audit" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_1",
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: { id: "survey_1" },
})
);
});
test("queues a failure audit log when the handler returns a non-ok response", async () => {
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
mockAuthenticateRequest.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
environmentPermissions: [],
});
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler: async ({ auditLog }) => {
if (auditLog) {
auditLog.targetId = "survey_2";
}
return new Response("forbidden", { status: 403 });
},
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys/survey_2", {
method: "DELETE",
headers: {
"x-request-id": "req-failure-audit",
"x-api-key": "fbk_test",
},
}),
{} as never
);
expect(response.status).toBe(403);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_2",
organizationId: "org_1",
userId: "key_1",
userType: "api",
status: "failure",
eventId: "req-failure-audit",
})
);
});
test("uses session auth first in both mode and injects request id into plain responses", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
+2 -76
View File
@@ -4,13 +4,10 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
import {
type InvalidParam,
problemBadRequest,
@@ -18,7 +15,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3AuditLog, TV3Authentication } from "./types";
import type { TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
@@ -41,7 +38,6 @@ export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unkn
req: NextRequest;
props: TProps;
authentication: TV3Authentication;
auditLog?: TV3AuditLog;
parsedInput: TParsedInput;
requestId: string;
instance: string;
@@ -52,8 +48,6 @@ export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = u
schemas?: S;
rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig;
action?: TAuditAction;
targetType?: TAuditTarget;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
};
@@ -299,61 +293,10 @@ async function applyV3RateLimitOrRespond(params: {
return null;
}
function buildV3AuditLog(
authentication: TV3Authentication,
action?: TAuditAction,
targetType?: TAuditTarget,
apiUrl?: string
): TV3AuditLog | undefined {
if (!authentication || !action || !targetType || !apiUrl) {
return undefined;
}
const auditLog = buildAuditLogBaseObject(action, targetType, apiUrl);
if ("user" in authentication && authentication.user?.id) {
auditLog.userId = authentication.user.id;
auditLog.userType = "user";
} else if ("apiKeyId" in authentication) {
auditLog.userId = authentication.apiKeyId;
auditLog.userType = "api";
auditLog.organizationId = authentication.organizationId;
}
return auditLog;
}
async function queueV3AuditLog(
auditLog: TV3AuditLog | undefined,
requestId: string,
log: ReturnType<typeof logger.withContext>
): Promise<void> {
if (!auditLog) {
return;
}
try {
await queueAuditEvent({
...auditLog,
...(auditLog.status === "failure" ? { eventId: auditLog.eventId ?? requestId } : {}),
});
} catch (error) {
log.error({ error }, "Failed to queue V3 audit event");
}
}
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const {
auth = "both",
schemas,
rateLimit = true,
customRateLimitConfig,
handler,
action,
targetType,
} = params;
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
@@ -363,7 +306,6 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
method: req.method,
path: instance,
});
let auditLog: TV3AuditLog | undefined;
try {
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
@@ -389,33 +331,17 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
return rateLimitResponse;
}
auditLog = buildV3AuditLog(authResult.authentication, action, targetType, req.url);
const response = await handler({
req,
props,
authentication: authResult.authentication,
auditLog,
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
});
if (auditLog) {
if (response.ok) {
auditLog.status = "success";
} else {
auditLog.eventId = requestId;
}
}
await queueV3AuditLog(auditLog, requestId, log);
return ensureRequestIdHeader(response, requestId);
} catch (error) {
if (auditLog) {
auditLog.eventId = requestId;
await queueV3AuditLog(auditLog, requestId, log);
}
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
-25
View File
@@ -7,7 +7,6 @@ import {
problemTooManyRequests,
problemUnauthorized,
successListResponse,
successResponse,
} from "./response";
describe("v3 problem responses", () => {
@@ -94,27 +93,3 @@ describe("successListResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
});
});
describe("successResponse", () => {
test("wraps the payload in a data envelope", async () => {
const res = successResponse({ id: "survey_1" }, { requestId: "req-success" });
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-success");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: { id: "survey_1" },
});
});
test("allows custom status and cache headers", async () => {
const res = successResponse(
{ ok: true },
{
cache: "private, max-age=60",
status: 202,
}
);
expect(res.status).toBe(202);
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
-24
View File
@@ -147,27 +147,3 @@ export function successListResponse<T, TMeta extends Record<string, unknown>>(
}
return Response.json({ data, meta }, { status: 200, headers });
}
export function successResponse<T>(
data: T,
options?: { requestId?: string; cache?: string; status?: number }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json(
{
data,
},
{
status: options?.status ?? 200,
headers,
}
);
}
-2
View File
@@ -1,6 +1,4 @@
import type { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import type { TApiAuditLog } from "@/app/lib/api/with-api-logging";
export type TV3Authentication = TAuthenticationApiKey | Session | null;
export type TV3AuditLog = TApiAuditLog;
@@ -1,321 +0,0 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { DELETE } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
const surveyId = "clxx1234567890123456789012";
const environmentId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) {
headers["x-request-id"] = requestId;
}
return new NextRequest(url, {
method: "DELETE",
headers,
});
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: true },
},
environmentPermissions: [
{
environmentId,
environmentType: EnvironmentType.development,
projectId: "proj_1",
projectName: "P",
permission: ApiKeyPermission.write,
},
],
};
describe("DELETE /api/v3/surveys/[surveyId]", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(getSurvey).mockResolvedValue({
id: surveyId,
name: "Delete me",
environmentId,
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: "User" },
singleUse: null,
} as any);
vi.mocked(deleteSurvey).mockResolvedValue({
id: surveyId,
environmentId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
environmentId,
projectId: "proj_1",
organizationId: "org_1",
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(401);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 200 with session auth and deletes the survey", async () => {
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
environmentId,
"readWrite",
"req-delete",
`/api/v3/surveys/${surveyId}`
);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(await res.json()).toEqual({
data: {
id: surveyId,
},
});
});
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const res = await DELETE(
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
"x-api-key": "fbk_test",
}),
{
params: Promise.resolve({ surveyId }),
} as never
);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
environmentId,
"readWrite",
"req-api-key",
`/api/v3/surveys/${surveyId}`
);
});
test("returns 400 when surveyId is invalid", async () => {
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
params: Promise.resolve({ surveyId: "not-a-cuid" }),
} as never);
expect(res.status).toBe(400);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 403 when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
});
test("returns 403 when the user lacks readWrite workspace access", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-forbidden",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "unknown",
organizationId: "unknown",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: undefined,
})
);
});
test("returns 500 when survey deletion fails", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: expect.objectContaining({
id: surveyId,
environmentId,
}),
})
);
});
test("queues an audit log with target, actor, organization, and old object", async () => {
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: expect.objectContaining({
id: surveyId,
environmentId,
}),
})
);
});
});
@@ -1,72 +0,0 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: z.object({
surveyId: z.cuid2(),
}),
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const survey = await getSurvey(surveyId);
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.environmentId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
if (auditLog) {
auditLog.targetId = survey.id;
auditLog.organizationId = authResult.organizationId;
auditLog.oldObject = survey;
}
const deletedSurvey = await deleteSurvey(surveyId);
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey delete unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
+1 -1
View File
@@ -321,11 +321,11 @@ describe("GET /api/v3/surveys", () => {
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0]).not.toHaveProperty("environmentId");
expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("env_1");
expect(body.data[0].singleUse).toBeNull();
});
test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => {
+2 -2
View File
@@ -1,6 +1,6 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurvey, "environmentId"> & {
export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
workspaceId: string;
};
@@ -9,7 +9,7 @@ export type TV3SurveyListItem = Omit<TSurvey, "environmentId"> & {
* Internally surveys are still scoped by environmentId; externally v3 exposes workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { environmentId, ...rest } = survey;
const { environmentId, singleUse: _omitSingleUse, ...rest } = survey;
return {
...rest,
+5 -5
View File
@@ -1647,14 +1647,14 @@ const identifyCustomerGoals = (t: TFunction): TTemplate => {
elements: [
buildMultipleChoiceElement({
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: t("templates.identify_customer_goals_question_1_headline"),
headline: "What's your primary goal for using $[projectName]?",
required: true,
shuffleOption: "none",
choices: [
t("templates.identify_customer_goals_question_1_choice_1"),
t("templates.identify_customer_goals_question_1_choice_2"),
t("templates.identify_customer_goals_question_1_choice_3"),
t("templates.identify_customer_goals_question_1_choice_4"),
"Understand my user base deeply",
"Identify upselling opportunities",
"Build the best possible product",
"Rule the world to make everyone breakfast brussels sprouts.",
],
}),
],
+11 -6
View File
@@ -315,6 +315,7 @@ checksums:
common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/other_placeholder: f3a0fa2eaaf75aa92b290449c928c081
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
common/password: 223a61cf906ab9c40d22612c588dff48
@@ -332,6 +333,7 @@ checksums:
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08
common/product_manager: dfeadc96e6d3de22a884ee97974b505e
common/production: 226e0ce83b49700bc1b1c08c4c3ed23a
@@ -466,6 +468,7 @@ checksums:
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
common/you_have_reached_your_limit_of_workspace_limit: 54d754c3267036742f23fb05fd3fcc45
@@ -1236,7 +1239,12 @@ checksums:
environments/settings/teams/you_are_a_member: cf5af638d5371c8fbc337e92519e5150
environments/surveys/all_set_time_to_create_first_survey: 21d3bb74c3b9642b3195d17c17346399
environments/surveys/alphabetical: 5fcfeff9c5fd28714f0a390e0ddaaaee
environments/surveys/copy_survey: de8142b45e7bca61f2dca0069a62b417
environments/surveys/copy_survey_description: 66d0aadf192ad5790fbf3f55f3bb5485
environments/surveys/copy_survey_error: 74cab7d84ea8b669e106d4c326cac005
environments/surveys/copy_survey_link_to_clipboard: 77387e3d3de4be07a2a34963f73cd7e8
environments/surveys/copy_survey_partially_success: a436a5fb7167b95c2308794d35aab070
environments/surveys/copy_survey_success: a829e645fe034b3e712d0b8572a5edc4
environments/surveys/delete_survey_and_responses_warning: 3320c91c1fd27378b7f3d6abc003f2ae
environments/surveys/edit/activate_translations: af127c1bed2b47e2012e3a23e489ecb8
environments/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66
@@ -1288,7 +1296,7 @@ checksums:
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
environments/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
environments/surveys/edit/auto_progress_rating_and_nps: 76b98e95a5b850850baa0ccc3c7fbf7c
environments/surveys/edit/auto_progress_rating_and_nps_description: 2a992dd8a5b9532f178f9a21881feb9a
environments/surveys/edit/auto_progress_rating_and_nps_description: cbf676789b9f3f47e36bdf35fa58282b
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
@@ -1957,6 +1965,7 @@ checksums:
environments/surveys/summary/downloading_qr_code: 3c46bf636e617848a4fca9b6c5b51dac
environments/surveys/summary/drop_offs: 605ee950f82110132d6c5780926af109
environments/surveys/summary/drop_offs_tooltip: 2a01683380be45f17636365886cf3452
environments/surveys/summary/failed_to_copy_link: 4e891c757c80e770674e8e74d1c08487
environments/surveys/summary/filter_added_successfully: e247f65020cd87454bcec0da6f0fd034
environments/surveys/summary/filter_updated_successfully: 01146bc7e6394e271836be2f1b3a257b
environments/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
@@ -2041,6 +2050,7 @@ checksums:
environments/surveys/summary/youre_not_plugged_in_yet: f19da3cd474b9a3cf28e956fd811fb00
environments/surveys/survey_deleted_successfully: a6b654cc914b344a4475fd2fd4a98cc5
environments/surveys/survey_duplicated_successfully: 91e244f1e7a33640bb4817166a01ff46
environments/surveys/survey_duplication_error: 35994330aed844ce37d8b4f09df24581
environments/surveys/templates/all_channels: 6be67a82fc7326dc2304b23ab3348b87
environments/surveys/templates/all_industries: c7354412fe34585526ff2232aadace41
environments/surveys/templates/all_roles: 6582ccd0a2349c162a7ae1574cdf76be
@@ -2722,11 +2732,6 @@ checksums:
templates/gauge_feature_satisfaction_question_2_headline: 0fcbefbfcf5c21e42de8a36cb2cad854
templates/identify_customer_goals_description: c30d06df9e5c76334e4c3d470ee6e4d8
templates/identify_customer_goals_name: f8123dbfa22e169517a811fae7496595
templates/identify_customer_goals_question_1_choice_1: a6803cfbdbd6208eedf5c691f9e106a5
templates/identify_customer_goals_question_1_choice_2: 7461749517d62030ec2e3915cf1d223b
templates/identify_customer_goals_question_1_choice_3: 725eb3ee0d4f2d229fcf588c21e66a86
templates/identify_customer_goals_question_1_choice_4: 3985521036afaf1cbd2bdc7a4d86d351
templates/identify_customer_goals_question_1_headline: bd9cd414fb723110d7f0a786bbf89d6c
templates/identify_sign_up_barriers_description: 5b2fbee8c425d7a4d0706ec3628cea11
templates/identify_sign_up_barriers_name: 3bbc5352dfa7a9c237bc2c6b21b608dd
templates/identify_sign_up_barriers_question_1_button_label: 080fd22c580f56ffdcea6c3d60448b84
+1 -20
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { isSafeIdentifier, toSafeIdentifier } from "./safe-identifier";
import { isSafeIdentifier } from "./safe-identifier";
describe("safe-identifier", () => {
describe("isSafeIdentifier", () => {
@@ -32,23 +32,4 @@ describe("safe-identifier", () => {
expect(isSafeIdentifier("")).toBe(false);
});
});
describe("toSafeIdentifier", () => {
test("normalizes free-form labels into safe identifiers", () => {
expect(toSafeIdentifier("Date of Birth")).toBe("date_of_birth");
expect(toSafeIdentifier("Customer-ID")).toBe("customer_id");
expect(toSafeIdentifier(" Preferred Language ")).toBe("preferred_language");
expect(toSafeIdentifier("city__name")).toBe("city_name");
});
test("strips invalid leading characters until first lowercase letter", () => {
expect(toSafeIdentifier("123 Date")).toBe("date");
expect(toSafeIdentifier("__name")).toBe("name");
expect(toSafeIdentifier("99")).toBe("");
});
test("keeps already safe identifiers unchanged", () => {
expect(toSafeIdentifier("country_code")).toBe("country_code");
});
});
});
-38
View File
@@ -12,44 +12,6 @@ export const isSafeIdentifier = (value: string): boolean => {
return /^[a-z0-9_]+$/.test(value);
};
/**
* Converts a free-form string to a safe identifier candidate.
* The output only contains lowercase letters, numbers, and underscores.
* It also ensures the identifier starts with a lowercase letter by stripping invalid leading chars.
*/
export const toSafeIdentifier = (value: string): string => {
const normalized = value.trim().toLowerCase();
let safeIdentifier = "";
let shouldInsertUnderscore = false;
for (const char of normalized) {
const isLowercaseLetter = char >= "a" && char <= "z";
const isDigit = char >= "0" && char <= "9";
if (isLowercaseLetter || isDigit) {
if (shouldInsertUnderscore && safeIdentifier.length > 0) {
safeIdentifier += "_";
}
safeIdentifier += char;
shouldInsertUnderscore = false;
continue;
}
if (safeIdentifier.length > 0) {
shouldInsertUnderscore = true;
}
}
for (let i = 0; i < safeIdentifier.length; i++) {
const char = safeIdentifier[i];
if (char >= "a" && char <= "z") {
return safeIdentifier.slice(i);
}
}
return "";
};
/**
* Converts a snake_case string to Title Case for display as a label.
* Example: "job_description" -> "Job Description"
+11 -6
View File
@@ -342,6 +342,7 @@
"other": "Andere",
"other_filters": "Weitere Filter",
"other_placeholder": "Sonstiger Platzhalter",
"others": "Andere",
"overlay_color": "Overlay-Farbe",
"overview": "Überblick",
"password": "Passwort",
@@ -359,6 +360,7 @@
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
"powered_by_formbricks": "Bereitgestellt von Formbricks",
"preview": "Vorschau",
"preview_survey": "Umfragevorschau",
"privacy": "Datenschutz",
"product_manager": "Produktmanager",
"production": "Produktion",
@@ -493,6 +495,7 @@
"workspace_name_placeholder": "z. B. Formbricks",
"workspaces": "Projekte",
"years": "Jahre",
"you": "Du",
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
"you_are_not_authorized_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion durchzuführen.",
"you_have_reached_your_limit_of_workspace_limit": "Sie haben Ihr Limit von {projectLimit} Workspaces erreicht.",
@@ -1306,7 +1309,12 @@
"surveys": {
"all_set_time_to_create_first_survey": "Alles klar! Zeit, deine erste Umfrage zu erstellen",
"alphabetical": "alphabetisch",
"copy_survey": "Umfrage kopieren",
"copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung",
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
"copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren",
"copy_survey_partially_success": "{success} Umfragen erfolgreich kopiert, {error} fehlgeschlagen.",
"copy_survey_success": "Umfrage erfolgreich kopiert!",
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
"edit": {
"activate_translations": "Übersetzungen aktivieren",
@@ -1359,7 +1367,7 @@
"audience": "Publikum",
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
"auto_progress_rating_and_nps": "Bewertungs- und NPS-Fragen automatisch fortsetzen",
"auto_progress_rating_and_nps_description": "Automatisches Weitergehen bei Einzelfragen-Blöcken. Pflichtfragen blenden Weiter aus, außer wenn \"Sonstiges\" ausgewählt ist.",
"auto_progress_rating_and_nps_description": "Fahre automatisch fort, sobald Befragte eine Antwort bei Bewertungs- oder NPS-Fragen auswählen. Dies gilt nur für Blöcke mit einer einzelnen Frage. Bei Pflichtfragen wird die Weiter-Schaltfläche ausgeblendet; bei optionalen Fragen bleibt sie zum Überspringen sichtbar.",
"auto_save_disabled": "Automatisches Speichern deaktiviert",
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
"auto_save_on": "Automatisches Speichern an",
@@ -2058,6 +2066,7 @@
"downloading_qr_code": "QR-Code wird heruntergeladen",
"drop_offs": "Drop-Off Rate",
"drop_offs_tooltip": "So oft wurde die Umfrage gestartet, aber nicht abgeschlossen.",
"failed_to_copy_link": "Kopieren des Links fehlgeschlagen",
"filter_added_successfully": "Filter erfolgreich hinzugefügt",
"filter_updated_successfully": "Filter erfolgreich aktualisiert",
"filtered_responses_csv": "Gefilterte Antworten (CSV)",
@@ -2145,6 +2154,7 @@
},
"survey_deleted_successfully": "Umfrage erfolgreich gelöscht",
"survey_duplicated_successfully": "Umfrage erfolgreich dupliziert",
"survey_duplication_error": "Duplizieren der Umfrage fehlgeschlagen",
"templates": {
"all_channels": "Alle Kanäle",
"all_industries": "Alle Branchen",
@@ -2877,11 +2887,6 @@
"gauge_feature_satisfaction_question_2_headline": "Was könnten wir besser machen?",
"identify_customer_goals_description": "Besser verstehen, ob deine Botschaften die richtigen Erwartungen an dein Produkt schaffen.",
"identify_customer_goals_name": "Kundenziele identifizieren",
"identify_customer_goals_question_1_choice_1": "Meine Nutzerbasis tiefgehend verstehen",
"identify_customer_goals_question_1_choice_2": "Upselling-Möglichkeiten identifizieren",
"identify_customer_goals_question_1_choice_3": "Das bestmögliche Produkt entwickeln",
"identify_customer_goals_question_1_choice_4": "Die Welt beherrschen, um allen Rosenkohl zum Frühstück zu servieren",
"identify_customer_goals_question_1_headline": "Was ist Ihr Hauptziel bei der Nutzung von $[projectName]?",
"identify_sign_up_barriers_description": "Biete einen Rabatt an, um Einblicke in Anmeldebarrieren zu gewinnen.",
"identify_sign_up_barriers_name": "Identifiziere Anmeldebarrieren",
"identify_sign_up_barriers_question_1_button_label": "Erhalte 10% Rabatt",
+11 -6
View File
@@ -342,6 +342,7 @@
"other": "Other",
"other_filters": "Other Filters",
"other_placeholder": "Other Placeholder",
"others": "Others",
"overlay_color": "Overlay color",
"overview": "Overview",
"password": "Password",
@@ -359,6 +360,7 @@
"please_upgrade_your_plan": "Please upgrade your plan",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "Preview",
"preview_survey": "Preview Survey",
"privacy": "Privacy Policy",
"product_manager": "Product Manager",
"production": "Production",
@@ -493,6 +495,7 @@
"workspace_name_placeholder": "e.g. Formbricks",
"workspaces": "Workspaces",
"years": "years",
"you": "You",
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
"you_have_reached_your_limit_of_workspace_limit": "You have reached your limit of {projectLimit} workspaces.",
@@ -1306,7 +1309,12 @@
"surveys": {
"all_set_time_to_create_first_survey": "You are all set! Time to create your first survey",
"alphabetical": "Alphabetical",
"copy_survey": "Copy survey",
"copy_survey_description": "Copy this survey to another environment",
"copy_survey_error": "Failed to copy survey",
"copy_survey_link_to_clipboard": "Copy survey link to clipboard",
"copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.",
"copy_survey_success": "Survey copied successfully",
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
"edit": {
"activate_translations": "Activate translations",
@@ -1359,7 +1367,7 @@
"audience": "Audience",
"auto_close_on_inactivity": "Auto close on inactivity",
"auto_progress_rating_and_nps": "Auto-progress rating and NPS questions",
"auto_progress_rating_and_nps_description": "Auto-advance in single-question blocks. Required questions hide Next, except when \"Other\" is selected.",
"auto_progress_rating_and_nps_description": "Automatically advance when respondents select an answer on rating or NPS questions. This only applies to single-question blocks. Required questions hide the Next button; optional questions still show it for skipping.",
"auto_save_disabled": "Auto-save disabled",
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
"auto_save_on": "Auto-save on",
@@ -2058,6 +2066,7 @@
"downloading_qr_code": "Downloading QR code",
"drop_offs": "Drop-Offs",
"drop_offs_tooltip": "Number of times the survey has been started but not completed.",
"failed_to_copy_link": "Failed to copy link",
"filter_added_successfully": "Filter added successfully",
"filter_updated_successfully": "Filter updated successfully",
"filtered_responses_csv": "Filtered responses (CSV)",
@@ -2145,6 +2154,7 @@
},
"survey_deleted_successfully": "Survey deleted successfully",
"survey_duplicated_successfully": "Survey duplicated successfully",
"survey_duplication_error": "Failed to duplicate the survey.",
"templates": {
"all_channels": "All channels",
"all_industries": "All industries",
@@ -2877,11 +2887,6 @@
"gauge_feature_satisfaction_question_2_headline": "What is one thing we could do better?",
"identify_customer_goals_description": "Better understand if your messaging creates the right expectations of the value your product provides.",
"identify_customer_goals_name": "Identify Customer Goals",
"identify_customer_goals_question_1_choice_1": "Understand my user base deeply",
"identify_customer_goals_question_1_choice_2": "Identify upselling opportunities",
"identify_customer_goals_question_1_choice_3": "Build the best possible product",
"identify_customer_goals_question_1_choice_4": "Rule the world to make everyone breakfast brussels sprouts",
"identify_customer_goals_question_1_headline": "What is your primary goal for using $[projectName]?",
"identify_sign_up_barriers_description": "Offer a discount to gather insights about sign up barriers.",
"identify_sign_up_barriers_name": "Identify Sign Up Barriers",
"identify_sign_up_barriers_question_1_button_label": "Get 10% discount",
+11 -6
View File
@@ -342,6 +342,7 @@
"other": "Otro",
"other_filters": "Otros Filtros",
"other_placeholder": "Otro marcador de posición",
"others": "Otros",
"overlay_color": "Color de superposición",
"overview": "Resumen",
"password": "Contraseña",
@@ -359,6 +360,7 @@
"please_upgrade_your_plan": "Por favor, actualiza tu plan",
"powered_by_formbricks": "Desarrollado por Formbricks",
"preview": "Vista previa",
"preview_survey": "Vista previa de la encuesta",
"privacy": "Política de privacidad",
"product_manager": "Gestor de producto",
"production": "Producción",
@@ -493,6 +495,7 @@
"workspace_name_placeholder": "p. ej. Formbricks",
"workspaces": "Proyectos",
"years": "años",
"you": "Tú",
"you_are_downgraded_to_the_community_edition": "Has sido degradado a la edición Community.",
"you_are_not_authorized_to_perform_this_action": "No tienes autorización para realizar esta acción.",
"you_have_reached_your_limit_of_workspace_limit": "Has alcanzado tu límite de {projectLimit} espacios de trabajo.",
@@ -1306,7 +1309,12 @@
"surveys": {
"all_set_time_to_create_first_survey": "¡Todo listo! Es hora de crear tu primera encuesta",
"alphabetical": "Alfabético",
"copy_survey": "Copiar encuesta",
"copy_survey_description": "Copia esta encuesta a otro entorno",
"copy_survey_error": "Error al copiar la encuesta",
"copy_survey_link_to_clipboard": "Copiar enlace de la encuesta al portapapeles",
"copy_survey_partially_success": "{success} encuestas copiadas correctamente, {error} fallidas.",
"copy_survey_success": "¡Encuesta copiada correctamente!",
"delete_survey_and_responses_warning": "¿Estás seguro de que quieres eliminar esta encuesta y todas sus respuestas?",
"edit": {
"activate_translations": "Activar traducciones",
@@ -1359,7 +1367,7 @@
"audience": "Audiencia",
"auto_close_on_inactivity": "Cierre automático por inactividad",
"auto_progress_rating_and_nps": "Avanzar automáticamente en preguntas de valoración y NPS",
"auto_progress_rating_and_nps_description": "Avance automático en bloques de una sola pregunta. Las preguntas obligatorias ocultan Siguiente, excepto cuando se selecciona \"Otro\".",
"auto_progress_rating_and_nps_description": "Avanza automáticamente cuando los encuestados seleccionen una respuesta en preguntas de valoración o NPS. Esto solo se aplica a bloques de una sola pregunta. Las preguntas obligatorias ocultan el botón Siguiente; las preguntas opcionales aún lo muestran para omitirlas.",
"auto_save_disabled": "Guardado automático desactivado",
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
"auto_save_on": "Guardado automático activado",
@@ -2058,6 +2066,7 @@
"downloading_qr_code": "Descargando código QR",
"drop_offs": "Abandonos",
"drop_offs_tooltip": "Número de veces que se ha iniciado la encuesta pero no se ha completado.",
"failed_to_copy_link": "Error al copiar el enlace",
"filter_added_successfully": "Filtro añadido correctamente",
"filter_updated_successfully": "Filtro actualizado correctamente",
"filtered_responses_csv": "Respuestas filtradas (CSV)",
@@ -2145,6 +2154,7 @@
},
"survey_deleted_successfully": "¡Encuesta eliminada correctamente!",
"survey_duplicated_successfully": "Encuesta duplicada correctamente.",
"survey_duplication_error": "Error al duplicar la encuesta.",
"templates": {
"all_channels": "Todos los canales",
"all_industries": "Todas las industrias",
@@ -2877,11 +2887,6 @@
"gauge_feature_satisfaction_question_2_headline": "¿Qué es una cosa que podríamos mejorar?",
"identify_customer_goals_description": "Comprende mejor si tus mensajes crean las expectativas correctas sobre el valor que proporciona tu producto.",
"identify_customer_goals_name": "Identificar objetivos del cliente",
"identify_customer_goals_question_1_choice_1": "Comprender en profundidad a mi base de usuarios",
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de venta adicional",
"identify_customer_goals_question_1_choice_3": "Construir el mejor producto posible",
"identify_customer_goals_question_1_choice_4": "Conquistar el mundo para que todos desayunen coles de Bruselas",
"identify_customer_goals_question_1_headline": "¿Cuál es tu objetivo principal al usar $[projectName]?",
"identify_sign_up_barriers_description": "Ofrece un descuento para obtener información sobre las barreras de registro.",
"identify_sign_up_barriers_name": "Identificar barreras de registro",
"identify_sign_up_barriers_question_1_button_label": "Obtener 10 % de descuento",
+11 -6
View File
@@ -342,6 +342,7 @@
"other": "Autre",
"other_filters": "Autres filtres",
"other_placeholder": "Autre espace réservé",
"others": "Autres",
"overlay_color": "Couleur de superposition",
"overview": "Aperçu",
"password": "Mot de passe",
@@ -359,6 +360,7 @@
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan",
"powered_by_formbricks": "Propulsé par Formbricks",
"preview": "Aperçu",
"preview_survey": "Aperçu de l'enquête",
"privacy": "Politique de confidentialité",
"product_manager": "Chef de produit",
"production": "Production",
@@ -493,6 +495,7 @@
"workspace_name_placeholder": "par ex. Formbricks",
"workspaces": "Projets",
"years": "années",
"you": "Vous",
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
"you_are_not_authorized_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.",
"you_have_reached_your_limit_of_workspace_limit": "Vous avez atteint votre limite de {projectLimit} espaces de travail.",
@@ -1306,7 +1309,12 @@
"surveys": {
"all_set_time_to_create_first_survey": "Vous êtes prêt ! Il est temps de créer votre première enquête.",
"alphabetical": "Alphabétique",
"copy_survey": "Copier l'enquête",
"copy_survey_description": "Copier cette enquête dans un autre environnement",
"copy_survey_error": "Échec de la copie du sondage",
"copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers",
"copy_survey_partially_success": "{success} enquêtes copiées avec succès, {error} échouées.",
"copy_survey_success": "Enquête copiée avec succès !",
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
"edit": {
"activate_translations": "Activer les traductions",
@@ -1359,7 +1367,7 @@
"audience": "Public",
"auto_close_on_inactivity": "Fermeture automatique en cas d'inactivité",
"auto_progress_rating_and_nps": "Progression automatique pour les questions d'évaluation et NPS",
"auto_progress_rating_and_nps_description": "Avancement automatique dans les blocs à question unique. Les questions obligatoires masquent le bouton Suivant, sauf lorsque « Autre » est sélection.",
"auto_progress_rating_and_nps_description": "Passe automatiquement à la question suivante lorsque les répondants sélectionnent une réponse aux questions d'évaluation ou NPS. Cela s'applique uniquement aux blocs à question unique. Les questions obligatoires masquent le bouton Suivant ; les questions facultatives l'affichent toujours pour permettre de passer la question.",
"auto_save_disabled": "Sauvegarde automatique désactivée",
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
"auto_save_on": "Sauvegarde automatique activée",
@@ -2058,6 +2066,7 @@
"downloading_qr_code": "Téléchargement du code QR",
"drop_offs": "Dépôts",
"drop_offs_tooltip": "Nombre de fois que l'enquête a été commencée mais non terminée.",
"failed_to_copy_link": "Échec de la copie du lien",
"filter_added_successfully": "Filtre ajouté avec succès",
"filter_updated_successfully": "Filtre mis à jour avec succès",
"filtered_responses_csv": "Réponses filtrées (CSV)",
@@ -2145,6 +2154,7 @@
},
"survey_deleted_successfully": "Enquête supprimée avec succès !",
"survey_duplicated_successfully": "Enquête dupliquée avec succès.",
"survey_duplication_error": "Échec de la duplication de l'enquête.",
"templates": {
"all_channels": "Tous les canaux",
"all_industries": "Tous les secteurs",
@@ -2877,11 +2887,6 @@
"gauge_feature_satisfaction_question_2_headline": "Quelle est une chose que nous pourrions améliorer ?",
"identify_customer_goals_description": "Mieux comprendre si votre message crée les bonnes attentes quant à la valeur que votre produit apporte.",
"identify_customer_goals_name": "Identifier les objectifs des clients",
"identify_customer_goals_question_1_choice_1": "Comprendre ma base d'utilisateurs en profondeur",
"identify_customer_goals_question_1_choice_2": "Identifier des opportunités de montée en gamme",
"identify_customer_goals_question_1_choice_3": "Créer le meilleur produit possible",
"identify_customer_goals_question_1_choice_4": "Conquérir le monde pour imposer des choux de Bruxelles au petit-déjeuner à tout le monde",
"identify_customer_goals_question_1_headline": "Quel est votre objectif principal pour l'utilisation de $[projectName] ?",
"identify_sign_up_barriers_description": "Offrir une remise pour recueillir des informations sur les obstacles à l'inscription.",
"identify_sign_up_barriers_name": "Identifier les obstacles à l'inscription",
"identify_sign_up_barriers_question_1_button_label": "Obtenez 10 % de réduction",
+11 -6
View File
@@ -342,6 +342,7 @@
"other": "Egyéb",
"other_filters": "Egyéb szűrők",
"other_placeholder": "Egyéb helyőrző",
"others": "Mások",
"overlay_color": "Rávetítés színe",
"overview": "Áttekintés",
"password": "Jelszó",
@@ -359,6 +360,7 @@
"please_upgrade_your_plan": "Váltson magasabb csomagra",
"powered_by_formbricks": "A gépházban: Formbricks",
"preview": "Előnézet",
"preview_survey": "Kérdőív előnézete",
"privacy": "Adatvédelmi irányelvek",
"product_manager": "Termékmenedzser",
"production": "Produktív",
@@ -493,6 +495,7 @@
"workspace_name_placeholder": "például Formbricks",
"workspaces": "Munkaterületek",
"years": "év",
"you": "Ön",
"you_are_downgraded_to_the_community_edition": "Visszaváltott a közösségi kiadásra.",
"you_are_not_authorized_to_perform_this_action": "Nincs felhatalmazva ennek a műveletnek a végrehajtásához.",
"you_have_reached_your_limit_of_workspace_limit": "Elérte a(z) {projectLimit} munkaterületből álló korlátot.",
@@ -1306,7 +1309,12 @@
"surveys": {
"all_set_time_to_create_first_survey": "Mindent beállított! Ideje létrehozni az első kérdőívet",
"alphabetical": "Ábécé-sorrend",
"copy_survey": "Kérdőív másolása",
"copy_survey_description": "A kérdőív másolása egy másik környezetbe",
"copy_survey_error": "Nem sikerült másolni a kérdőívet",
"copy_survey_link_to_clipboard": "Kérdőív hivatkozásának másolása a vágólapra",
"copy_survey_partially_success": "{success} kérdőív sikeresen másolva, {error} sikertelen.",
"copy_survey_success": "A kérdőív sikeresen másolva",
"delete_survey_and_responses_warning": "Biztosan törölni szeretné ezt a kérdőívet és az összes válaszát?",
"edit": {
"activate_translations": "Fordítások aktiválása",
@@ -1359,7 +1367,7 @@
"audience": "Közönség",
"auto_close_on_inactivity": "Automatikus lezárás tétlenségnél",
"auto_progress_rating_and_nps": "Automatikus továbblépés értékelési és NPS kérdéseknél",
"auto_progress_rating_and_nps_description": "Automatikus továbblépés egy kérdést tartalmazó blokkokban. A kötelező kérdések elrejtik a Tovább gombot, kivéve amikor az „Egyéb“ opció van kiválasztva.",
"auto_progress_rating_and_nps_description": "Automatikus továbblépés, amikor a válaszadók kiválasztanak egy választ az értékelési vagy NPS kérdéseknél. Ez csak az egykérdéses blokkokra vonatkozik. A kötelező kérdések elrejtik a Tovább gombot; az opcionális kérdések továbbra is megjelenítik azt a kihagyás lehetősége érdekében.",
"auto_save_disabled": "Az automatikus mentés letiltva",
"auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.",
"auto_save_on": "Automatikus mentés bekapcsolva",
@@ -2058,6 +2066,7 @@
"downloading_qr_code": "QR-kód letöltése",
"drop_offs": "Megszakítások",
"drop_offs_tooltip": "A kérdőív elkezdési, de be nem fejezési alkalmainak száma.",
"failed_to_copy_link": "Nem sikerült a hivatkozás másolása",
"filter_added_successfully": "A szűrő sikeresen hozzáadva",
"filter_updated_successfully": "A szűrő sikeresen frissítve",
"filtered_responses_csv": "Szűrt válaszok (CSV)",
@@ -2145,6 +2154,7 @@
},
"survey_deleted_successfully": "A kérdőív sikeresen törölve",
"survey_duplicated_successfully": "A kérdőív sikeresen megkettőzve",
"survey_duplication_error": "Nem sikerült megkettőzni a kérdőívet.",
"templates": {
"all_channels": "Összes csatorna",
"all_industries": "Összes iparág",
@@ -2877,11 +2887,6 @@
"gauge_feature_satisfaction_question_2_headline": "Mi az egyetlen dolog, amelyet jobban csinálhatnánk?",
"identify_customer_goals_description": "Jobban megérteni, hogy az üzenetei a termék által nyújtott érték megfelelő elvárásait keltik-e.",
"identify_customer_goals_name": "Ügyfélcélok azonosítása",
"identify_customer_goals_question_1_choice_1": "Alaposan megismerni a felhasználói bázisomat",
"identify_customer_goals_question_1_choice_2": "Felülértékesítési lehetőségek azonosítása",
"identify_customer_goals_question_1_choice_3": "A lehető legjobb termék elkészítése",
"identify_customer_goals_question_1_choice_4": "Világuralmat szerezni, hogy mindenki kelbimbót egyen reggelire",
"identify_customer_goals_question_1_headline": "Mi az elsődleges célja a(z) $[projectName] használatával?",
"identify_sign_up_barriers_description": "Kedvezmény felajánlása a regisztrációs akadályokkal kapcsolatos tapasztalatok gyűjtéséhez.",
"identify_sign_up_barriers_name": "Regisztrációs akadályok azonosítása",
"identify_sign_up_barriers_question_1_button_label": "10% kedvezmény",
+11 -6
View File
@@ -342,6 +342,7 @@
"other": "その他",
"other_filters": "その他のフィルター",
"other_placeholder": "その他のプレースホルダー",
"others": "その他",
"overlay_color": "オーバーレイの色",
"overview": "概要",
"password": "パスワード",
@@ -359,6 +360,7 @@
"please_upgrade_your_plan": "プランをアップグレードしてください",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "プレビュー",
"preview_survey": "フォームをプレビュー",
"privacy": "プライバシーポリシー",
"product_manager": "プロダクトマネージャー",
"production": "本番",
@@ -493,6 +495,7 @@
"workspace_name_placeholder": "例: Formbricks",
"workspaces": "ワークスペース",
"years": "年",
"you": "あなた",
"you_are_downgraded_to_the_community_edition": "コミュニティ版にダウングレードされました。",
"you_are_not_authorized_to_perform_this_action": "このアクションを実行する権限がありません。",
"you_have_reached_your_limit_of_workspace_limit": "ワークスペースの上限である{projectLimit}件に達しました。",
@@ -1306,7 +1309,12 @@
"surveys": {
"all_set_time_to_create_first_survey": "すべての準備が整いました!最初のフォームを作成しましょう",
"alphabetical": "アルファベット順",
"copy_survey": "フォームをコピー",
"copy_survey_description": "このフォームを別の環境にコピー",
"copy_survey_error": "フォームのコピーに失敗しました",
"copy_survey_link_to_clipboard": "フォームのリンクをクリップボードにコピー",
"copy_survey_partially_success": "{success} 個のフォームが正常にコピーされ、{error} 個が失敗しました。",
"copy_survey_success": "フォームを正常にコピーしました!",
"delete_survey_and_responses_warning": "本当にこのフォームとすべての回答を削除しますか?",
"edit": {
"activate_translations": "翻訳を有効化",
@@ -1359,7 +1367,7 @@
"audience": "オーディエンス",
"auto_close_on_inactivity": "非アクティブ時に自動閉鎖",
"auto_progress_rating_and_nps": "評価とNPSの質問を自動進行",
"auto_progress_rating_and_nps_description": "単一質問ブロックで自動的に次へ進みます。必須質問では「次へ」ボタンが非表示になりますが、「その他」が選択された場合は表示されます。",
"auto_progress_rating_and_nps_description": "評価またはNPSの質問で回答者が選択肢を選んだ際に自動的に次へ進みます。これは単一質問ブロックにのみ適用されます。必須質問では「次へ」ボタンが非表示になり、任意の質問ではスキップ用に引き続き表示されます。",
"auto_save_disabled": "自動保存が無効",
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
"auto_save_on": "自動保存オン",
@@ -2058,6 +2066,7 @@
"downloading_qr_code": "QRコードをダウンロード中",
"drop_offs": "離脱",
"drop_offs_tooltip": "フォームが開始されたが完了しなかった回数。",
"failed_to_copy_link": "リンクのコピーに失敗しました",
"filter_added_successfully": "フィルターを正常に追加しました",
"filter_updated_successfully": "フィルターを正常に更新しました",
"filtered_responses_csv": "フィルター済み回答 (CSV)",
@@ -2145,6 +2154,7 @@
},
"survey_deleted_successfully": "フォームを正常に削除しました!",
"survey_duplicated_successfully": "フォームを正常に複製しました。",
"survey_duplication_error": "フォームの複製に失敗しました。",
"templates": {
"all_channels": "すべてのチャネル",
"all_industries": "すべての業界",
@@ -2877,11 +2887,6 @@
"gauge_feature_satisfaction_question_2_headline": "私たちがもっとうまくできることは何ですか?",
"identify_customer_goals_description": "あなたのメッセージが製品の価値に対する正しい期待を抱かせているかどうかをよりよく理解する。",
"identify_customer_goals_name": "顧客目標の特定",
"identify_customer_goals_question_1_choice_1": "ユーザーベースを深く理解する",
"identify_customer_goals_question_1_choice_2": "アップセルの機会を特定する",
"identify_customer_goals_question_1_choice_3": "最高の製品を構築する",
"identify_customer_goals_question_1_choice_4": "世界を支配して全員に朝食に芽キャベツを食べさせる",
"identify_customer_goals_question_1_headline": "$[projectName]を使用する主な目的は何ですか?",
"identify_sign_up_barriers_description": "サインアップの障壁に関する洞察を得るために割引を提供する。",
"identify_sign_up_barriers_name": "サインアップの障壁を特定する",
"identify_sign_up_barriers_question_1_button_label": "10%割引を取得",
+11 -6
View File
@@ -342,6 +342,7 @@
"other": "Ander",
"other_filters": "Overige filters",
"other_placeholder": "Andere tijdelijke aanduiding",
"others": "Anderen",
"overlay_color": "Overlaykleur",
"overview": "Overzicht",
"password": "Wachtwoord",
@@ -359,6 +360,7 @@
"please_upgrade_your_plan": "Upgrade je abonnement",
"powered_by_formbricks": "Mogelijk gemaakt door Formbricks",
"preview": "Voorbeeld",
"preview_survey": "Voorbeeld van enquête",
"privacy": "Privacybeleid",
"product_manager": "Productmanager",
"production": "Productie",
@@ -493,6 +495,7 @@
"workspace_name_placeholder": "bijv. Formbricks",
"workspaces": "Werkruimtes",
"years": "jaren",
"you": "Jij",
"you_are_downgraded_to_the_community_edition": "Je bent gedowngraded naar de Community-editie.",
"you_are_not_authorized_to_perform_this_action": "U bent niet geautoriseerd om deze actie uit te voeren.",
"you_have_reached_your_limit_of_workspace_limit": "Je hebt je limiet van {projectLimit} werkruimtes bereikt.",
@@ -1306,7 +1309,12 @@
"surveys": {
"all_set_time_to_create_first_survey": "Je bent helemaal klaar! Tijd om uw eerste enquête te maken",
"alphabetical": "Alfabetisch",
"copy_survey": "Kopieer enquête",
"copy_survey_description": "Kopieer deze enquête naar een andere omgeving",
"copy_survey_error": "Het kopiëren van de enquête is mislukt",
"copy_survey_link_to_clipboard": "Kopieer de enquêtelink naar het klembord",
"copy_survey_partially_success": "{success} enquêtes zijn succesvol gekopieerd, {error} is mislukt.",
"copy_survey_success": "Enquête succesvol gekopieerd!",
"delete_survey_and_responses_warning": "Weet u zeker dat u deze enquête en alle antwoorden erop wilt verwijderen?",
"edit": {
"activate_translations": "Vertalingen activeren",
@@ -1359,7 +1367,7 @@
"audience": "Publiek",
"auto_close_on_inactivity": "Automatisch sluiten bij inactiviteit",
"auto_progress_rating_and_nps": "Automatisch doorgaan bij beoordelings- en NPS-vragen",
"auto_progress_rating_and_nps_description": "Automatisch doorgaan bij blokken met één vraag. Verplichte vragen verbergen Volgende, behalve wanneer \"Anders\" is geselecteerd.",
"auto_progress_rating_and_nps_description": "Ga automatisch verder wanneer respondenten een antwoord selecteren bij beoordelings- of NPS-vragen. Dit geldt alleen voor blokken met één vraag. Bij verplichte vragen wordt de Volgende-knop verborgen; bij optionele vragen blijft deze zichtbaar om de vraag over te slaan.",
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
"auto_save_on": "Automatisch opslaan aan",
@@ -2058,6 +2066,7 @@
"downloading_qr_code": "QR-code downloaden",
"drop_offs": "Drop-offs",
"drop_offs_tooltip": "Aantal keren dat de enquête is gestart maar niet is voltooid.",
"failed_to_copy_link": "Kan de link niet kopiëren",
"filter_added_successfully": "Filter succesvol toegevoegd",
"filter_updated_successfully": "Filter succesvol bijgewerkt",
"filtered_responses_csv": "Gefilterde reacties (CSV)",
@@ -2145,6 +2154,7 @@
},
"survey_deleted_successfully": "Enquête succesvol verwijderd!",
"survey_duplicated_successfully": "Enquête is succesvol gedupliceerd.",
"survey_duplication_error": "Het is niet gelukt de enquête te dupliceren.",
"templates": {
"all_channels": "Alle kanalen",
"all_industries": "Alle industrieën",
@@ -2877,11 +2887,6 @@
"gauge_feature_satisfaction_question_2_headline": "Wat kunnen we beter doen?",
"identify_customer_goals_description": "Begrijp beter of uw boodschap de juiste verwachtingen wekt van de waarde die uw product biedt.",
"identify_customer_goals_name": "Identificeer klantdoelen",
"identify_customer_goals_question_1_choice_1": "Mijn gebruikersgroep grondig begrijpen",
"identify_customer_goals_question_1_choice_2": "Upselling-mogelijkheden identificeren",
"identify_customer_goals_question_1_choice_3": "Het best mogelijke product bouwen",
"identify_customer_goals_question_1_choice_4": "De wereld regeren om iedereen spruitjes als ontbijt te geven",
"identify_customer_goals_question_1_headline": "Wat is je primaire doel voor het gebruik van $[projectName]?",
"identify_sign_up_barriers_description": "Bied een korting aan om inzicht te krijgen in de aanmeldingsbarrières.",
"identify_sign_up_barriers_name": "Identificeer aanmeldingsbarrières",
"identify_sign_up_barriers_question_1_button_label": "Krijg 10% korting",
+11 -6
View File
@@ -342,6 +342,7 @@
"other": "outro",
"other_filters": "Outros Filtros",
"other_placeholder": "Outro espaço reservado",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão Geral",
"password": "Senha",
@@ -359,6 +360,7 @@
"please_upgrade_your_plan": "Por favor, atualize seu plano",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"preview": "Prévia",
"preview_survey": "Prévia da Pesquisa",
"privacy": "Política de Privacidade",
"product_manager": "Gerente de Produto",
"production": "Produção",
@@ -493,6 +495,7 @@
"workspace_name_placeholder": "ex: Formbricks",
"workspaces": "Projetos",
"years": "anos",
"you": "Você",
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
"you_are_not_authorized_to_perform_this_action": "Você não tem autorização para realizar essa ação.",
"you_have_reached_your_limit_of_workspace_limit": "Você atingiu seu limite de {projectLimit} espaços de trabalho.",
@@ -1306,7 +1309,12 @@
"surveys": {
"all_set_time_to_create_first_survey": "Tá tudo pronto! Hora de criar sua primeira pesquisa",
"alphabetical": "alfabético",
"copy_survey": "Copiar pesquisa",
"copy_survey_description": "Copiar essa pesquisa para outro ambiente",
"copy_survey_error": "Falha ao copiar pesquisa",
"copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência",
"copy_survey_partially_success": "{success} pesquisas copiadas com sucesso, {error} falharam.",
"copy_survey_success": "Pesquisa copiada com sucesso!",
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?",
"edit": {
"activate_translations": "Ativar traduções",
@@ -1359,7 +1367,7 @@
"audience": "Público",
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
"auto_progress_rating_and_nps": "Avançar automaticamente em perguntas de avaliação e NPS",
"auto_progress_rating_and_nps_description": "Avança automaticamente em blocos de pergunta única. Perguntas obrigatórias ocultam o botão Próximo, exceto quando \"Outro\" está selecionado.",
"auto_progress_rating_and_nps_description": "Avança automaticamente quando os respondentes selecionam uma resposta em perguntas de avaliação ou NPS. Isso se aplica apenas a blocos com uma única pergunta. Perguntas obrigatórias ocultam o botão Próximo; perguntas opcionais ainda o exibem para permitir pular.",
"auto_save_disabled": "Salvamento automático desativado",
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
"auto_save_on": "Salvamento automático ativado",
@@ -2058,6 +2066,7 @@
"downloading_qr_code": "Baixando código QR",
"drop_offs": "Pontos de Entrega",
"drop_offs_tooltip": "Número de vezes que a pesquisa foi iniciada mas não concluída.",
"failed_to_copy_link": "Falha ao copiar link",
"filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso",
"filtered_responses_csv": "Respostas filtradas (CSV)",
@@ -2145,6 +2154,7 @@
},
"survey_deleted_successfully": "Pesquisa deletada com sucesso!",
"survey_duplicated_successfully": "Pesquisa duplicada com sucesso.",
"survey_duplication_error": "Falha ao duplicar a pesquisa.",
"templates": {
"all_channels": "Todos os canais",
"all_industries": "Todas as indústrias",
@@ -2877,11 +2887,6 @@
"gauge_feature_satisfaction_question_2_headline": "O que a gente poderia melhorar?",
"identify_customer_goals_description": "Entenda melhor se sua mensagem cria as expectativas certas sobre o valor que seu produto oferece.",
"identify_customer_goals_name": "Identificar Objetivos do Cliente",
"identify_customer_goals_question_1_choice_1": "Entender profundamente minha base de usuários",
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de upsell",
"identify_customer_goals_question_1_choice_3": "Construir o melhor produto possível",
"identify_customer_goals_question_1_choice_4": "Dominar o mundo para fazer todo mundo tomar couve de bruxelas no café da manhã",
"identify_customer_goals_question_1_headline": "Qual é o seu objetivo principal ao usar $[projectName]?",
"identify_sign_up_barriers_description": "Ofereça um desconto pra entender melhor as barreiras de cadastro.",
"identify_sign_up_barriers_name": "Identificar Barreiras de Cadastro",
"identify_sign_up_barriers_question_1_button_label": "Ganhe 10% de desconto",
+11 -6
View File
@@ -342,6 +342,7 @@
"other": "Outro",
"other_filters": "Outros Filtros",
"other_placeholder": "Outro espaço reservado",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão geral",
"password": "Palavra-passe",
@@ -359,6 +360,7 @@
"please_upgrade_your_plan": "Por favor, atualize o seu plano",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"preview": "Pré-visualização",
"preview_survey": "Pré-visualização do inquérito",
"privacy": "Política de Privacidade",
"product_manager": "Gestor de Produto",
"production": "Produção",
@@ -493,6 +495,7 @@
"workspace_name_placeholder": "ex. Formbricks",
"workspaces": "Projetos",
"years": "anos",
"you": "Você",
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
"you_are_not_authorized_to_perform_this_action": "Não está autorizado a realizar esta ação.",
"you_have_reached_your_limit_of_workspace_limit": "Atingiu o seu limite de {projectLimit} áreas de trabalho.",
@@ -1306,7 +1309,12 @@
"surveys": {
"all_set_time_to_create_first_survey": "Está tudo pronto! Hora de criar o seu primeiro inquérito",
"alphabetical": "Alfabética",
"copy_survey": "Copiar inquérito",
"copy_survey_description": "Copiar este questionário para outro ambiente",
"copy_survey_error": "Falha ao copiar inquérito",
"copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência",
"copy_survey_partially_success": "{success} inquéritos copiados com sucesso, {error} falharam.",
"copy_survey_success": "Inquérito copiado com sucesso!",
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas?",
"edit": {
"activate_translations": "Ativar traduções",
@@ -1359,7 +1367,7 @@
"audience": "Público",
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
"auto_progress_rating_and_nps": "Avançar automaticamente em perguntas de classificação e NPS",
"auto_progress_rating_and_nps_description": "Avança automaticamente em blocos de pergunta única. Perguntas obrigatórias ocultam o botão Seguinte, exceto quando \"Outro\" está selecionado.",
"auto_progress_rating_and_nps_description": "Avança automaticamente quando os inquiridos selecionam uma resposta em perguntas de classificação ou NPS. Isto aplica-se apenas a blocos com uma única pergunta. Perguntas obrigatórias ocultam o botão Seguinte; perguntas opcionais continuam a mostrá-lo para permitir saltar.",
"auto_save_disabled": "Guardar automático desativado",
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
"auto_save_on": "Guardar automático ativado",
@@ -2058,6 +2066,7 @@
"downloading_qr_code": "A transferir código QR",
"drop_offs": "Desistências",
"drop_offs_tooltip": "Número de vezes que o inquérito foi iniciado mas não concluído.",
"failed_to_copy_link": "Falha ao copiar link",
"filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso",
"filtered_responses_csv": "Respostas filtradas (CSV)",
@@ -2145,6 +2154,7 @@
},
"survey_deleted_successfully": "Inquérito eliminado com sucesso!",
"survey_duplicated_successfully": "Inquérito duplicado com sucesso.",
"survey_duplication_error": "Falha ao duplicar o inquérito.",
"templates": {
"all_channels": "Todos os canais",
"all_industries": "Todas as indústrias",
@@ -2877,11 +2887,6 @@
"gauge_feature_satisfaction_question_2_headline": "O que é uma coisa que poderíamos fazer melhor?",
"identify_customer_goals_description": "Compreenda melhor se a sua mensagem cria as expectativas certas sobre o valor que o seu produto oferece.",
"identify_customer_goals_name": "Identificar Objetivos do Cliente",
"identify_customer_goals_question_1_choice_1": "Compreender profundamente a minha base de utilizadores",
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de upselling",
"identify_customer_goals_question_1_choice_3": "Construir o melhor produto possível",
"identify_customer_goals_question_1_choice_4": "Dominar o mundo para fazer couves de Bruxelas ao pequeno-almoço para todos",
"identify_customer_goals_question_1_headline": "Qual é o seu objetivo principal ao usar $[projectName]?",
"identify_sign_up_barriers_description": "Ofereça um desconto para obter informações sobre as barreiras de inscrição.",
"identify_sign_up_barriers_name": "Identificar Barreiras de Inscrição",
"identify_sign_up_barriers_question_1_button_label": "Obtenha 10% de desconto",
+11 -6
View File
@@ -342,6 +342,7 @@
"other": "Altele",
"other_filters": "Alte Filtre",
"other_placeholder": "Alt substituent",
"others": "Altele",
"overlay_color": "Culoare overlay",
"overview": "Prezentare generală",
"password": "Parolă",
@@ -359,6 +360,7 @@
"please_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră",
"powered_by_formbricks": "Oferit de Formbricks",
"preview": "Previzualizare",
"preview_survey": "Previzualizare Chestionar",
"privacy": "Politica de Confidențialitate",
"product_manager": "Manager de Produs",
"production": "Producție",
@@ -493,6 +495,7 @@
"workspace_name_placeholder": "ex: Formbricks",
"workspaces": "Workspaces",
"years": "ani",
"you": "Tu",
"you_are_downgraded_to_the_community_edition": "Ai fost retrogradat la ediția Community.",
"you_are_not_authorized_to_perform_this_action": "Nu sunteți autorizat să efectuați această acțiune.",
"you_have_reached_your_limit_of_workspace_limit": "Ați atins limita de {projectLimit} spații de lucru.",
@@ -1306,7 +1309,12 @@
"surveys": {
"all_set_time_to_create_first_survey": "Ești gata! Este timpul să creezi primul tău chestionar",
"alphabetical": "Alfabetic",
"copy_survey": "Copiază sondajul",
"copy_survey_description": "Copiază acest sondaj într-un alt mediu",
"copy_survey_error": "Nu s-a putut copia sondajul",
"copy_survey_link_to_clipboard": "Copiază linkul chestionarului în clipboard",
"copy_survey_partially_success": "\"{success} sondaje copiate cu succes, {error} eșuate.\"",
"copy_survey_success": "\"Sondaj copiat cu succes!\"",
"delete_survey_and_responses_warning": "Sigur doriți să ștergeți acest sondaj și toate răspunsurile sale?",
"edit": {
"activate_translations": "Activează traducerile",
@@ -1359,7 +1367,7 @@
"audience": "Public",
"auto_close_on_inactivity": "Închidere automată la inactivitate",
"auto_progress_rating_and_nps": "Avansare automată pentru întrebări de rating și NPS",
"auto_progress_rating_and_nps_description": "Avansare automată în blocurile cu o singură întrebare. Întrebările obligatorii ascund butonul Următorul, cu excepția cazului în care este selectată opțiunea „Altele“.",
"auto_progress_rating_and_nps_description": "Avansează automat când respondenții selectează un răspuns la întrebările de rating sau NPS. Aceasta se aplică doar blocurilor cu o singură întrebare. Întrebările obligatorii ascund butonul Următorul; întrebările opționale îl afișează în continuare pentru a permite omiterea.",
"auto_save_disabled": "Salvare automată dezactivată",
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
"auto_save_on": "Salvare automată activată",
@@ -2058,6 +2066,7 @@
"downloading_qr_code": "Se descarcă codul QR",
"drop_offs": "Renunțări",
"drop_offs_tooltip": "Număr de ori când sondajul a fost început dar nu a fost finalizat.",
"failed_to_copy_link": "Nu s-a putut copia legătura",
"filter_added_successfully": "Filtru adăugat cu succes",
"filter_updated_successfully": "Filtru actualizat cu succes",
"filtered_responses_csv": "Răspunsuri filtrate (CSV)",
@@ -2145,6 +2154,7 @@
},
"survey_deleted_successfully": "\"Sondaj șters cu succes!\"",
"survey_duplicated_successfully": "\"Sondaj duplicat cu succes!\"",
"survey_duplication_error": "Eșec la duplicarea sondajului.",
"templates": {
"all_channels": "Toate canalele",
"all_industries": "Toate industriile",
@@ -2877,11 +2887,6 @@
"gauge_feature_satisfaction_question_2_headline": "Care este acel lucru pe care l-am putea îmbunătăți?",
"identify_customer_goals_description": "Înțelegeți mai bine dacă mesajele voastre creează așteptările corecte privind valoarea pe care o oferă produsul vostru.",
"identify_customer_goals_name": "Identifică Obiectivele Clienților",
"identify_customer_goals_question_1_choice_1": "Să îmi înțeleg în profunzime baza de utilizatori",
"identify_customer_goals_question_1_choice_2": "Să identific oportunități de upselling",
"identify_customer_goals_question_1_choice_3": "Să construiesc cel mai bun produs posibil",
"identify_customer_goals_question_1_choice_4": "Să cuceresc lumea pentru a-i face tuturor la micul dejun varză de Bruxelles",
"identify_customer_goals_question_1_headline": "Care este obiectivul tău principal pentru utilizarea $[projectName]?",
"identify_sign_up_barriers_description": "Oferiți o reducere pentru a obține informații despre barierele de înscriere.",
"identify_sign_up_barriers_name": "Identificați barierele de înscriere",
"identify_sign_up_barriers_question_1_button_label": "Obține reducere de 10%",
+11 -6
View File
@@ -342,6 +342,7 @@
"other": "Другое",
"other_filters": "Другие фильтры",
"other_placeholder": "Другой заполнитель",
"others": "Другие",
"overlay_color": "Цвет наложения",
"overview": "Обзор",
"password": "Пароль",
@@ -359,6 +360,7 @@
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план",
"powered_by_formbricks": "Работает на Formbricks",
"preview": "Предпросмотр",
"preview_survey": "Предпросмотр опроса",
"privacy": "Политика конфиденциальности",
"product_manager": "Менеджер продукта",
"production": "Продакшн",
@@ -493,6 +495,7 @@
"workspace_name_placeholder": "например, Formbricks",
"workspaces": "Рабочие пространства",
"years": "годы",
"you": "Вы",
"you_are_downgraded_to_the_community_edition": "Ваша версия понижена до Community Edition.",
"you_are_not_authorized_to_perform_this_action": "У вас нет прав для выполнения этого действия.",
"you_have_reached_your_limit_of_workspace_limit": "Вы достигли лимита в {projectLimit} рабочих пространств.",
@@ -1306,7 +1309,12 @@
"surveys": {
"all_set_time_to_create_first_survey": "Всё готово! Пора создать первый опрос",
"alphabetical": "По алфавиту",
"copy_survey": "Копировать опрос",
"copy_survey_description": "Скопируйте этот опрос в другую среду",
"copy_survey_error": "Не удалось скопировать опрос",
"copy_survey_link_to_clipboard": "Скопировать ссылку на опрос в буфер обмена",
"copy_survey_partially_success": "Успешно скопировано опросов: {success}, не удалось: {error}.",
"copy_survey_success": "Опрос успешно скопирован!",
"delete_survey_and_responses_warning": "Вы уверены, что хотите удалить этот опрос и все его ответы?",
"edit": {
"activate_translations": "Активировать переводы",
@@ -1359,7 +1367,7 @@
"audience": "Аудитория",
"auto_close_on_inactivity": "Автоматически закрывать при бездействии",
"auto_progress_rating_and_nps": "Автоматический переход для вопросов с оценкой и NPS",
"auto_progress_rating_and_nps_description": "Автоматический переход в блоках с одним вопросом. Обязательные вопросы скрывают кнопку «Далее», за исключением случаев, когда выбран вариант «Другое».",
"auto_progress_rating_and_nps_description": "Автоматически переходить к следующему шагу, когда респонденты выбирают ответ в вопросах с оценкой или NPS. Это применяется только к блокам с одним вопросом. В обязательных вопросах кнопка «Далее» скрыта; в необязательных вопросах она остается видимой для пропуска.",
"auto_save_disabled": "Автосохранение отключено",
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
"auto_save_on": "Автосохранение включено",
@@ -2058,6 +2066,7 @@
"downloading_qr_code": "Скачивание QR-кода",
"drop_offs": "Прерывания",
"drop_offs_tooltip": "Количество раз, когда опрос был начат, но не завершён.",
"failed_to_copy_link": "Не удалось скопировать ссылку",
"filter_added_successfully": "Фильтр успешно добавлен",
"filter_updated_successfully": "Фильтр успешно обновлён",
"filtered_responses_csv": "Отфильтрованные ответы (CSV)",
@@ -2145,6 +2154,7 @@
},
"survey_deleted_successfully": "Опрос успешно удалён!",
"survey_duplicated_successfully": "Опрос успешно продублирован.",
"survey_duplication_error": "Не удалось продублировать опрос.",
"templates": {
"all_channels": "Все каналы",
"all_industries": "Все отрасли",
@@ -2877,11 +2887,6 @@
"gauge_feature_satisfaction_question_2_headline": "Что мы могли бы сделать лучше?",
"identify_customer_goals_description": "Лучше понять, создают ли ваши сообщения правильные ожидания относительно ценности вашего продукта.",
"identify_customer_goals_name": "Определение целей клиента",
"identify_customer_goals_question_1_choice_1": "Глубоко понять свою пользовательскую базу",
"identify_customer_goals_question_1_choice_2": "Выявить возможности для допродаж",
"identify_customer_goals_question_1_choice_3": "Создать наилучший продукт",
"identify_customer_goals_question_1_choice_4": "Править миром, чтобы накормить всех брюссельской капустой на завтрак",
"identify_customer_goals_question_1_headline": "Какова ваша основная цель использования $[projectName]?",
"identify_sign_up_barriers_description": "Предложите скидку, чтобы узнать, что мешает регистрации.",
"identify_sign_up_barriers_name": "Определение барьеров регистрации",
"identify_sign_up_barriers_question_1_button_label": "Получить скидку 10%",
+11 -6
View File
@@ -342,6 +342,7 @@
"other": "Annat",
"other_filters": "Andra filter",
"other_placeholder": "Annan platshållare",
"others": "Andra",
"overlay_color": "Overlay-färg",
"overview": "Översikt",
"password": "Lösenord",
@@ -359,6 +360,7 @@
"please_upgrade_your_plan": "Vänligen uppgradera din plan",
"powered_by_formbricks": "Drivs av Formbricks",
"preview": "Förhandsgranska",
"preview_survey": "Förhandsgranska enkät",
"privacy": "Integritetspolicy",
"product_manager": "Produktchef",
"production": "Produktion",
@@ -493,6 +495,7 @@
"workspace_name_placeholder": "t.ex. Formbricks",
"workspaces": "Arbetsytor",
"years": "år",
"you": "Du",
"you_are_downgraded_to_the_community_edition": "Du har nedgraderats till Community Edition.",
"you_are_not_authorized_to_perform_this_action": "Du har inte behörighet att utföra denna åtgärd.",
"you_have_reached_your_limit_of_workspace_limit": "Du har nått din gräns på {projectLimit} arbetsytor.",
@@ -1306,7 +1309,12 @@
"surveys": {
"all_set_time_to_create_first_survey": "Allt klart! Dags att skapa din första enkät",
"alphabetical": "Alfabetisk",
"copy_survey": "Kopiera enkät",
"copy_survey_description": "Kopiera denna enkät till en annan miljö",
"copy_survey_error": "Misslyckades med att kopiera enkät",
"copy_survey_link_to_clipboard": "Kopiera enkätlänk till urklipp",
"copy_survey_partially_success": "{success} enkäter kopierade, {error} misslyckades.",
"copy_survey_success": "Enkät kopierad!",
"delete_survey_and_responses_warning": "Är du säker på att du vill ta bort denna enkät och alla dess svar?",
"edit": {
"activate_translations": "Aktivera översättningar",
@@ -1359,7 +1367,7 @@
"audience": "Målgrupp",
"auto_close_on_inactivity": "Stäng automatiskt vid inaktivitet",
"auto_progress_rating_and_nps": "Gå vidare automatiskt vid betygs- och NPS-frågor",
"auto_progress_rating_and_nps_description": "Gå automatiskt vidare i block med en enda fråga. Obligatoriska frågor döljer Nästa, utom när \"Annat\" är valt.",
"auto_progress_rating_and_nps_description": "Gå automatiskt vidare när respondenter väljer ett svar på betygs- eller NPS-frågor. Detta gäller endast block med en enda fråga. Obligatoriska frågor döljer Nästa-knappen; valfria frågor visar den fortfarande för att kunna hoppas över.",
"auto_save_disabled": "Automatisk sparning inaktiverad",
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
"auto_save_on": "Automatisk sparning på",
@@ -2058,6 +2066,7 @@
"downloading_qr_code": "Laddar ner QR-kod",
"drop_offs": "Avhopp",
"drop_offs_tooltip": "Antal gånger enkäten har startats men inte slutförts.",
"failed_to_copy_link": "Misslyckades med att kopiera länk",
"filter_added_successfully": "Filter tillagt",
"filter_updated_successfully": "Filter uppdaterat",
"filtered_responses_csv": "Filtrerade svar (CSV)",
@@ -2145,6 +2154,7 @@
},
"survey_deleted_successfully": "Enkät borttagen!",
"survey_duplicated_successfully": "Enkät duplicerad.",
"survey_duplication_error": "Misslyckades med att duplicera enkäten.",
"templates": {
"all_channels": "Alla kanaler",
"all_industries": "Alla branscher",
@@ -2877,11 +2887,6 @@
"gauge_feature_satisfaction_question_2_headline": "Vad är en sak vi kunde göra bättre?",
"identify_customer_goals_description": "Förstå bättre om din kommunikation skapar rätt förväntningar på värdet din produkt ger.",
"identify_customer_goals_name": "Identifiera kundmål",
"identify_customer_goals_question_1_choice_1": "Förstå min användarbas på djupet",
"identify_customer_goals_question_1_choice_2": "Identifiera merförsäljningsmöjligheter",
"identify_customer_goals_question_1_choice_3": "Bygga bästa möjliga produkt",
"identify_customer_goals_question_1_choice_4": "Härska över världen för att få alla att äta brysselkål till frukost",
"identify_customer_goals_question_1_headline": "Vad är ditt primära mål med att använda $[projectName]?",
"identify_sign_up_barriers_description": "Erbjud en rabatt för att samla insikter om registreringshinder.",
"identify_sign_up_barriers_name": "Identifiera registreringshinder",
"identify_sign_up_barriers_question_1_button_label": "Få 10% rabatt",
+11 -6
View File
@@ -342,6 +342,7 @@
"other": "其他",
"other_filters": "其他筛选条件",
"other_placeholder": "其他占位符",
"others": "其他",
"overlay_color": "覆盖层颜色",
"overview": "概览",
"password": "密码",
@@ -359,6 +360,7 @@
"please_upgrade_your_plan": "请升级您的计划",
"powered_by_formbricks": "由 Formbricks 提供支持",
"preview": "预览",
"preview_survey": "预览 Survey",
"privacy": "隐私政策",
"product_manager": "产品经理",
"production": "生产环境",
@@ -493,6 +495,7 @@
"workspace_name_placeholder": "例如:Formbricks",
"workspaces": "工作区",
"years": "年",
"you": "你 ",
"you_are_downgraded_to_the_community_edition": "您已降级到社区版。",
"you_are_not_authorized_to_perform_this_action": "您无权执行此操作。",
"you_have_reached_your_limit_of_workspace_limit": "您已达到 {projectLimit} 个工作区的上限。",
@@ -1306,7 +1309,12 @@
"surveys": {
"all_set_time_to_create_first_survey": "一切准备就绪!是时候创建您的第一个调查了",
"alphabetical": "字母顺序",
"copy_survey": "复制 调查",
"copy_survey_description": "复制 此 调查 到 另 一个 环境",
"copy_survey_error": "复制 调查 失败",
"copy_survey_link_to_clipboard": "复制 survey 链接 到 剪贴板",
"copy_survey_partially_success": "{success} 个调查成功复制,{error} 个失败。",
"copy_survey_success": "调查成功复制!",
"delete_survey_and_responses_warning": "您 确定 要 删除 此 调查 及 所有 回复 吗?",
"edit": {
"activate_translations": "激活翻译",
@@ -1359,7 +1367,7 @@
"audience": "受众",
"auto_close_on_inactivity": "自动关闭 在 无活动时",
"auto_progress_rating_and_nps": "自动推进评分和 NPS 问题",
"auto_progress_rating_and_nps_description": "在单问题块中自动前进。必填问题会隐藏\"下一步\"按钮,除非选择了\"其他\"选项。",
"auto_progress_rating_and_nps_description": "当受访者在评分或 NPS 问题上选择答案时自动前进。这仅适用于单问题区块。必填问题会隐藏\"下一步\"按钮;可选问题仍会显示该按钮以便跳过。",
"auto_save_disabled": "自动保存已禁用",
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
"auto_save_on": "自动保存已启用",
@@ -2058,6 +2066,7 @@
"downloading_qr_code": "正在下载二维码",
"drop_offs": "流失",
"drop_offs_tooltip": "调查 被 开始 但 未 完成 的 次数",
"failed_to_copy_link": "复制链接失败",
"filter_added_successfully": "筛选器 添加成功",
"filter_updated_successfully": "筛选器 更新 成功",
"filtered_responses_csv": "过滤 反馈 CSV",
@@ -2145,6 +2154,7 @@
},
"survey_deleted_successfully": "调查 删除 成功",
"survey_duplicated_successfully": "调查成功复制。",
"survey_duplication_error": "无法复制 调查。",
"templates": {
"all_channels": "所有 渠道",
"all_industries": "所有 行业",
@@ -2877,11 +2887,6 @@
"gauge_feature_satisfaction_question_2_headline": "我们 可以 改进 的 一 件事 是 什么?",
"identify_customer_goals_description": "更好 地 了解 您 的 信息 是否 创造了 您 的 产品 所 提供 价值 的 正确 期望。",
"identify_customer_goals_name": "识别 客户 目标",
"identify_customer_goals_question_1_choice_1": "深入了解我的用户群体",
"identify_customer_goals_question_1_choice_2": "识别追加销售机会",
"identify_customer_goals_question_1_choice_3": "打造最优质的产品",
"identify_customer_goals_question_1_choice_4": "统治世界,让每个人早餐都吃抱子甘蓝",
"identify_customer_goals_question_1_headline": "您使用 $[projectName] 的主要目标是什么?",
"identify_sign_up_barriers_description": "提供折扣以收集有关 注册障碍 的见解。",
"identify_sign_up_barriers_name": "识别 注册 障碍",
"identify_sign_up_barriers_question_1_button_label": "获取 10% 折扣",
+11 -6
View File
@@ -342,6 +342,7 @@
"other": "其他",
"other_filters": "其他篩選條件",
"other_placeholder": "其他預設文字",
"others": "其他",
"overlay_color": "覆蓋層顏色",
"overview": "概覽",
"password": "密碼",
@@ -359,6 +360,7 @@
"please_upgrade_your_plan": "請升級您的方案",
"powered_by_formbricks": "由 Formbricks 提供技術支援",
"preview": "預覽",
"preview_survey": "預覽問卷",
"privacy": "隱私權政策",
"product_manager": "產品經理",
"production": "正式環境",
@@ -493,6 +495,7 @@
"workspace_name_placeholder": "例如:Formbricks",
"workspaces": "工作區",
"years": "年",
"you": "您",
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
"you_are_not_authorized_to_perform_this_action": "您沒有執行此操作的權限。",
"you_have_reached_your_limit_of_workspace_limit": "您已達到 {projectLimit} 個工作區的上限。",
@@ -1306,7 +1309,12 @@
"surveys": {
"all_set_time_to_create_first_survey": "您已準備就緒!是時候建立您的第一個問卷",
"alphabetical": "依字母順序",
"copy_survey": "複製問卷",
"copy_survey_description": "將此問卷複製到另一個環境",
"copy_survey_error": "無法複製問卷",
"copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿",
"copy_survey_partially_success": "{success} 個問卷已成功複製,{error} 個失敗。",
"copy_survey_success": "問卷已成功複製!",
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?",
"edit": {
"activate_translations": "啟用翻譯",
@@ -1359,7 +1367,7 @@
"audience": "受眾",
"auto_close_on_inactivity": "非活動時自動關閉",
"auto_progress_rating_and_nps": "自動前進評分與 NPS 問題",
"auto_progress_rating_and_nps_description": "在單一問題區塊中自動前進。必填問題會隱藏「下一步」按鈕,除非選擇了「其他」選項。",
"auto_progress_rating_and_nps_description": "當受訪者在評分或 NPS 問題中選擇答案時自動前進。此設定僅適用於單一問題區塊。必填問題會隱藏「下一步」按鈕;選填問題仍會顯示該按鈕以便跳過。",
"auto_save_disabled": "自動儲存已停用",
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
"auto_save_on": "自動儲存已啟用",
@@ -2058,6 +2066,7 @@
"downloading_qr_code": "正在下載 QR code",
"drop_offs": "放棄",
"drop_offs_tooltip": "問卷已開始但未完成的次數。",
"failed_to_copy_link": "無法複製連結",
"filter_added_successfully": "篩選器已成功新增",
"filter_updated_successfully": "篩選器已成功更新",
"filtered_responses_csv": "篩選回應 (CSV)",
@@ -2145,6 +2154,7 @@
},
"survey_deleted_successfully": "問卷已成功刪除!",
"survey_duplicated_successfully": "問卷已成功複製。",
"survey_duplication_error": "無法複製問卷。",
"templates": {
"all_channels": "所有管道",
"all_industries": "所有產業",
@@ -2877,11 +2887,6 @@
"gauge_feature_satisfaction_question_2_headline": "我們可以做哪一件事來改進?",
"identify_customer_goals_description": "更瞭解您的訊息傳遞是否符合您的產品所提供價值的正確期望。",
"identify_customer_goals_name": "識別客戶目標",
"identify_customer_goals_question_1_choice_1": "深入了解我的使用者群",
"identify_customer_goals_question_1_choice_2": "找出向上銷售的機會",
"identify_customer_goals_question_1_choice_3": "打造最優秀的產品",
"identify_customer_goals_question_1_choice_4": "統治世界,讓每個人都吃早餐球芽甘藍",
"identify_customer_goals_question_1_headline": "您使用 $[projectName] 的主要目標是什麼?",
"identify_sign_up_barriers_description": "提供折扣以收集有關註冊障礙的洞察。",
"identify_sign_up_barriers_name": "識別註冊障礙",
"identify_sign_up_barriers_question_1_button_label": "獲得 10% 折扣",
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TResponse } from "@formbricks/types/responses";
import { TUserLocale } from "@formbricks/types/user";
import { Button } from "@/modules/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface InfoIconButtonProps {
@@ -25,11 +26,9 @@ const InfoIconButton = ({
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex h-4 w-4 items-center justify-center rounded text-slate-500 hover:text-slate-700"
aria-label={ariaLabel}>
<Button variant="outline" size="icon" aria-label={ariaLabel}>
<Icon className="h-4 w-4" />
</button>
</Button>
</TooltipTrigger>
<TooltipContent avoidCollisions align="start" side="bottom" className={maxWidth}>
{tooltipContent}
@@ -1,38 +0,0 @@
import { describe, expect, test } from "vitest";
import { V3ApiError, getV3ApiErrorMessage, parseV3ApiError } from "@/modules/api/lib/v3-client";
describe("parseV3ApiError", () => {
test("parses RFC 9457 error responses into a typed V3ApiError", async () => {
const response = new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
requestId: "req_1",
invalid_params: [{ name: "surveyId", reason: "Invalid id" }],
}),
{
status: 403,
headers: {
"Content-Type": "application/problem+json",
"X-Request-Id": "req_1",
},
}
);
const error = await parseV3ApiError(response);
expect(error).toBeInstanceOf(V3ApiError);
expect(error.status).toBe(403);
expect(error.detail).toBe("You are not authorized to access this resource");
expect(error.code).toBe("forbidden");
expect(error.requestId).toBe("req_1");
expect(error.invalid_params).toEqual([{ name: "surveyId", reason: "Invalid id" }]);
});
test("falls back to a provided fallback message", () => {
expect(getV3ApiErrorMessage(new Error("boom"), "fallback")).toBe("boom");
expect(getV3ApiErrorMessage("bad", "fallback")).toBe("fallback");
});
});
-74
View File
@@ -1,74 +0,0 @@
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,
});
}
@@ -7,7 +7,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { formatSnakeCaseToTitleCase, isSafeIdentifier, toSafeIdentifier } from "@/lib/utils/safe-identifier";
import { formatSnakeCaseToTitleCase, isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -57,27 +57,25 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
};
const handleNameChange = (value: string) => {
const previousAutoKey = toSafeIdentifier(formData.name);
const newAutoKey = toSafeIdentifier(value);
const shouldAutoUpdateKey = !formData.key || formData.key === previousAutoKey;
setFormData((prev) => ({
...prev,
name: value,
key: shouldAutoUpdateKey ? newAutoKey : prev.key,
}));
if (shouldAutoUpdateKey && keyError) {
if (newAutoKey) {
validateKey(newAutoKey);
} else {
setKeyError("");
}
setFormData((prev) => ({ ...prev, name: value }));
if (keyError && formData.key) {
validateKey(formData.key);
}
};
const handleKeyChange = (value: string) => {
setFormData((prev) => ({ ...prev, key: value }));
const previousAutoLabel = formData.key ? formatSnakeCaseToTitleCase(formData.key) : "";
const newAutoLabel = value ? formatSnakeCaseToTitleCase(value) : "";
setFormData((prev) => {
// Auto-update name if it's empty or matches the previous auto-generated label
const shouldAutoUpdateName = !prev.name || prev.name === previousAutoLabel;
return {
...prev,
key: value,
name: shouldAutoUpdateName ? newAutoLabel : prev.name,
};
});
validateKey(value);
};
@@ -165,17 +163,6 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
<form onSubmit={handleSubmit}>
<DialogBody>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-900">
{t("environments.contacts.attribute_label")}
</label>
<Input
value={formData.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={t("environments.contacts.attribute_label_placeholder")}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-900">
{t("environments.contacts.attribute_key")}
@@ -190,6 +177,17 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
<p className="text-xs text-slate-500">{t("environments.contacts.attribute_key_hint")}</p>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-900">
{t("environments.contacts.attribute_label")}
</label>
<Input
value={formData.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={t("environments.contacts.attribute_label_placeholder")}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-900">
{t("environments.contacts.data_type")}
@@ -1,7 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
import { Dialog, DialogContent } from "@/modules/ui/components/dialog";
import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
interface ProjectLimitModalProps {
@@ -17,7 +17,6 @@ export const ProjectLimitModal = ({ open, setOpen, projectLimit, buttons }: Proj
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogTitle className="sr-only">{t("common.unlock_more_workspaces_with_a_higher_plan")}</DialogTitle>
<UpgradePrompt
title={t("common.unlock_more_workspaces_with_a_higher_plan")}
description={t("common.you_have_reached_your_limit_of_workspace_limit", { projectLimit })}
-140
View File
@@ -1,140 +0,0 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey } from "./surveys";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
},
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
const environmentId = "clq5n7p1q0000m7z0h5p6g3r3";
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
const mockDeletedSurveyAppPrivateSegment = {
id: surveyId,
environmentId,
type: "app",
segment: { id: segmentId, isPrivate: true },
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
};
const mockDeletedSurveyLink = {
id: surveyId,
environmentId,
type: "link",
segment: null,
triggers: [],
};
describe("deleteSurvey", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("should delete a link survey without a segment", async () => {
const deleteMock = vi.fn().mockResolvedValue(mockDeletedSurveyLink);
const segmentDeleteMock = vi.fn();
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
callback({
survey: { delete: deleteMock },
segment: { delete: segmentDeleteMock },
} as never)
);
const deletedSurvey = await deleteSurvey(surveyId);
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
expect(deleteMock).toHaveBeenCalledWith({
where: { id: surveyId },
include: {
segment: true,
triggers: { include: { actionClass: true } },
},
});
expect(segmentDeleteMock).not.toHaveBeenCalled();
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
});
test("should delete a private segment for app surveys", async () => {
const deleteMock = vi.fn().mockResolvedValue(mockDeletedSurveyAppPrivateSegment);
const segmentDeleteMock = vi.fn().mockResolvedValue({ id: segmentId });
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
callback({
survey: { delete: deleteMock },
segment: { delete: segmentDeleteMock },
} as never)
);
const deletedSurvey = await deleteSurvey(surveyId);
expect(segmentDeleteMock).toHaveBeenCalledWith({ where: { id: segmentId } });
expect(deletedSurvey).toEqual(mockDeletedSurveyAppPrivateSegment);
});
test("should map Prisma P2025 during survey deletion to ResourceNotFoundError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "4.0.0",
});
vi.mocked(prisma.$transaction).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(ResourceNotFoundError);
expect(logger.warn).toHaveBeenCalledWith({ surveyId }, "Survey not found during delete");
expect(logger.error).not.toHaveBeenCalled();
});
test("should handle non-P2025 PrismaClientKnownRequestError during survey deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Constraint failed", {
code: "P2003",
clientVersion: "4.0.0",
});
vi.mocked(prisma.$transaction).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
});
test("should handle generic errors during deletion", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.$transaction).mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
});
test("should throw validation error for invalid surveyId", async () => {
const invalidSurveyId = "invalid-id";
const validationError = new Error("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
-51
View File
@@ -1,51 +0,0 @@
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;
}
};
+127 -1
View File
@@ -2,18 +2,52 @@
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 { copySurveyToOtherEnvironment } from "@/modules/survey/list/lib/survey";
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);
});
const ZCopySurveyToOtherEnvironmentAction = z.object({
surveyId: z.cuid2(),
@@ -93,6 +127,62 @@ 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(),
@@ -120,3 +210,39 @@ 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
);
});
@@ -0,0 +1,223 @@
"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>
);
};
@@ -0,0 +1,44 @@
"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,12 +1,11 @@
"use client";
import { TSortOption } from "@formbricks/types/surveys/types";
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
import { DropdownMenuItem } from "@/modules/ui/components/dropdown-menu";
interface SortOptionProps {
option: TSortOption;
sortBy: TSurveyOverviewFilters["sortBy"];
sortBy: TSurveyFilters["sortBy"];
handleSortChange: (option: TSortOption) => void;
}
@@ -8,25 +8,27 @@ 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 { TSurveyListItem } from "@/modules/survey/list/types/survey-overview";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { SurveyDropDownMenu } from "./survey-dropdown-menu";
interface SurveyCardProps {
survey: TSurveyListItem;
survey: TSurvey;
environmentId: string;
publicDomain: string;
isReadOnly: boolean;
deleteSurvey: (surveyId: string) => Promise<void>;
publicDomain: string;
deleteSurvey: (surveyId: string) => void;
locale: TUserLocale;
onSurveysCopied?: () => void;
}
export const SurveyCard = ({
survey,
environmentId,
publicDomain,
isReadOnly,
publicDomain,
deleteSurvey,
locale,
onSurveysCopied,
}: SurveyCardProps) => {
const { t } = useTranslation();
const surveyStatusLabel = (() => {
@@ -54,53 +56,43 @@ export const SurveyCard = ({
const isDraftAndReadOnly = survey.status === "draft" && isReadOnly;
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>
const CardContent = (
<>
<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",
survey.status === "inProgress" && "bg-emerald-50",
survey.status === "completed" && "bg-slate-200",
survey.status === "draft" && "bg-slate-100",
survey.status === "paused" && "bg-slate-100"
"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"
)}>
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}{" "}
<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>
</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>
</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">
<button className="absolute right-3 top-3.5" onClick={(e) => e.stopPropagation()}>
<SurveyDropDownMenu
survey={survey}
key={`surveys-${survey.id}`}
@@ -109,8 +101,17 @@ export const SurveyCard = ({
disabled={isDraftAndReadOnly}
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
deleteSurvey={deleteSurvey}
onSurveysCopied={onSurveysCopied}
/>
</div>
</div>
</button>
</>
);
return isDraftAndReadOnly ? (
<div className="relative block">{CardContent}</div>
) : (
<Link href={linkHref} key={survey.id} className="relative block">
{CardContent}
</Link>
);
};
@@ -0,0 +1,50 @@
"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,6 +1,14 @@
"use client";
import { EyeIcon, LinkIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import {
ArrowUpFromLineIcon,
CopyIcon,
EyeIcon,
LinkIcon,
MoreVertical,
SquarePenIcon,
TrashIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
@@ -8,10 +16,15 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { cn } from "@/lib/cn";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { TSurveyListItem } from "@/modules/survey/list/types/survey-overview";
import {
copySurveyToOtherEnvironmentAction,
deleteSurveyAction,
getSurveyAction,
} from "@/modules/survey/list/actions";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
@@ -20,14 +33,16 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { CopySurveyModal } from "./copy-survey-modal";
interface SurveyDropDownMenuProps {
environmentId: string;
survey: TSurveyListItem;
survey: TSurvey;
publicDomain: string;
disabled?: boolean;
isSurveyCreationDeletionDisabled?: boolean;
deleteSurvey: (surveyId: string) => Promise<void>;
deleteSurvey: (surveyId: string) => void;
onSurveysCopied?: () => void;
}
export const SurveyDropDownMenu = ({
@@ -37,29 +52,32 @@ 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 editHref = `/environments/${environmentId}/surveys/${survey.id}/edit`;
const surveyLink = useMemo(() => `${publicDomain}/s/${survey.id}`, [publicDomain, survey.id]);
const surveyLink = useMemo(() => publicDomain + "/s/" + survey.id, [survey.id, publicDomain]);
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 {
await deleteSurvey(surveyId);
const result = await deleteSurveyAction({ surveyId });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
deleteSurvey(surveyId);
toast.success(t("environments.surveys.survey_deleted_successfully"));
} catch (error) {
toast.error(getV3ApiErrorMessage(error, t("environments.surveys.error_deleting_survey")));
toast.error(t("environments.surveys.error_deleting_survey"));
} finally {
setLoading(false);
}
@@ -69,93 +87,147 @@ export const SurveyDropDownMenu = ({
try {
e.preventDefault();
setIsDropDownOpen(false);
await navigator.clipboard.writeText(copySurveyLink(surveyLink));
// For single-use surveys, this button is disabled, so we just copy the base link
const copiedLink = copySurveyLink(surveyLink);
navigator.clipboard.writeText(copiedLink);
toast.success(t("common.copied_to_clipboard"));
} catch (error) {
logger.error(error);
toast.error(t("common.something_went_wrong_please_try_again"));
toast.error(t("environments.surveys.summary.failed_to_copy_link"));
}
};
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}>
<button
type="button"
data-testid="survey-dropdown-trigger"
aria-label={t("environments.surveys.open_options")}
<div
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" />
</button>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max">
<DropdownMenuGroup>
{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 && (
<>
<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>
</>
)}
{canPreviewOrCopyLink && (
{!isSurveyCreationDeletionDisabled && (
<DropdownMenuItem>
<button
type="button"
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
className="flex w-full items-center"
disabled={loading}
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
const previewUrl = new URL(surveyLink);
previewUrl.searchParams.set("preview", "true");
globalThis.window.open(previewUrl.toString(), "_blank");
setIsCopyFormOpen(true);
}}>
<EyeIcon className="mr-2 h-4 w-4" />
{t("common.preview")}
<ArrowUpFromLineIcon className="mr-2 h-4 w-4" />
{t("common.copy")}...
</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>
{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>
</>
)}
{canManageSurvey && (
{!isSurveyCreationDeletionDisabled && (
<DropdownMenuItem>
<button
type="button"
@@ -174,7 +246,7 @@ export const SurveyDropDownMenu = ({
</DropdownMenuContent>
</DropdownMenu>
{canManageSurvey && (
{!isSurveyCreationDeletionDisabled && (
<DeleteDialog
deleteWhat={t("common.survey")}
open={isDeleteDialogOpen}
@@ -185,20 +257,26 @@ export const SurveyDropDownMenu = ({
/>
)}
{canManageSurvey && survey.responseCount > 0 && (
{survey.responseCount > 0 && (
<EditPublicSurveyAlertDialog
open={isCautionDialogOpen}
setOpen={setIsCautionDialogOpen}
isLoading={loading}
primaryButtonAction={async () => {
await duplicateSurveyAndRefresh(survey.id);
setIsCautionDialogOpen(false);
router.push(editHref);
}}
primaryButtonText={t("common.edit")}
secondaryButtonAction={() => setIsCautionDialogOpen(false)}
secondaryButtonText={t("common.cancel")}
primaryButtonText={t("common.duplicate")}
secondaryButtonAction={() =>
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`)
}
secondaryButtonText={t("common.edit")}
/>
)}
{isCopyFormOpen && (
<CopySurveyModal open={isCopyFormOpen} setOpen={setIsCopyFormOpen} survey={survey} />
)}
</div>
);
};
@@ -12,7 +12,7 @@ import {
interface SurveyFilterDropdownProps {
title: string;
id: "status" | "type";
id: "createdBy" | "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 { type Dispatch, type SetStateAction, useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useDebounce } from "react-use";
import type { TProjectConfigChannel } from "@formbricks/types/project";
import type { TFilterOption, TSortOption } from "@formbricks/types/surveys/types";
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 { 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,11 +20,16 @@ import { SearchBar } from "@/modules/ui/components/search-bar";
import { SurveyFilterDropdown } from "./survey-filter-dropdown";
interface SurveyFilterProps {
surveyFilters: TSurveyOverviewFilters;
setSurveyFilters: Dispatch<SetStateAction<TSurveyOverviewFilters>>;
surveyFilters: TSurveyFilters;
setSurveyFilters: React.Dispatch<React.SetStateAction<TSurveyFilters>>;
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" },
@@ -56,10 +61,10 @@ export const SurveyFilters = ({
setSurveyFilters,
currentProjectChannel,
}: SurveyFilterProps) => {
const { sortBy, status, type } = surveyFilters;
const [name, setName] = useState(surveyFilters.name);
const { createdBy, sortBy, status, type } = surveyFilters;
const [name, setName] = useState("");
const { t } = useTranslation();
useDebounce(() => setSurveyFilters((prev) => ({ ...prev, name })), 800, [name]);
useDebounce(() => setSurveyFilters((prev) => ({ ...prev, name: name })), 800, [name]);
const [dropdownOpenStates, setDropdownOpenStates] = useState(new Map());
@@ -68,14 +73,20 @@ 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)) {
@@ -109,6 +120,17 @@ 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")}
@@ -134,12 +156,13 @@ export const SurveyFilters = ({
</div>
)}
{(status.length > 0 || type.length > 0 || name) && (
{(createdBy.length > 0 || status.length > 0 || type.length > 0 || name) && (
<Button
size="sm"
onClick={() => {
setSurveyFilters(initialFilters);
setName(initialFilters.name);
setName(""); // Also clear the search input
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}}
className="h-8">
{t("common.clear_filters")}
@@ -1,244 +1,195 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { PlusIcon } from "lucide-react";
import Link from "next/link";
import { type ComponentProps, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useCallback, 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 { 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 { getSurveysAction } from "@/modules/survey/list/actions";
import { initialFilters } from "@/modules/survey/list/lib/constants";
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 { getFormattedFilters } from "@/modules/survey/list/lib/utils";
import { TSurvey } from "@/modules/survey/list/types/surveys";
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 {
environment: ComponentProps<typeof TemplateContainerWithPreview>["environment"];
project: ComponentProps<typeof TemplateContainerWithPreview>["project"];
userId: string;
publicDomain: string;
environmentId: string;
isReadOnly: boolean;
publicDomain: string;
userId: string;
surveysPerPage: number;
currentProjectChannel: TProjectConfigChannel;
locale: TUserLocale;
}
export const SurveysList = ({
environment,
project,
userId,
publicDomain,
environmentId,
isReadOnly,
surveysPerPage,
publicDomain,
userId,
surveysPerPage: surveysLimit,
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<TSurveyOverviewFilters>(initialFilters);
const [surveyFilters, setSurveyFilters] = useState<TSurveyFilters>(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 globalThis.window === "undefined") {
return;
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);
}
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 || typeof globalThis.window === "undefined") {
return;
if (isFilterInitialized) {
localStorage.setItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS, JSON.stringify(surveyFilters));
}
}, [surveyFilters, isFilterInitialized]);
globalThis.window.localStorage.setItem(
FORMBRICKS_SURVEYS_FILTERS_KEY_LS,
JSON.stringify(normalizedFilters)
);
}, [normalizedFilters, isFilterInitialized]);
useEffect(() => {
// Wait for filters to be loaded from localStorage before fetching
if (!isFilterInitialized) return;
const {
error,
fetchNextPage,
hasNextPage,
isError,
isFetchingNextPage,
isLoading,
queryKey,
refetch,
surveys,
totalCount,
} = useSurveys({
workspaceId: environment.id,
limit: surveysPerPage,
filters: normalizedFilters,
enabled: 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 deleteSurveyMutation = useDeleteSurvey({ queryKey });
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 hasAppliedFilters = hasActiveSurveyFilters(normalizedFilters);
const showInitialLoading = !isFilterInitialized || (isLoading && surveys.length === 0);
const showTemplateEmptyState = !isError && totalCount === 0 && !hasAppliedFilters && !isReadOnly;
const showReadOnlyEmptyState = !isError && totalCount === 0 && !hasAppliedFilters && isReadOnly;
setSurveys([...surveys, ...res.data]);
setIsFetching(false);
}
}, [environmentId, surveys, surveysLimit, filters]);
const handleDeleteSurvey = async (surveyId: string) => {
await deleteSurveyMutation.mutateAsync({ surveyId });
const newSurveys = surveys.filter((survey) => survey.id !== surveyId);
setSurveys(newSurveys);
if (newSurveys.length === 0) {
setIsFetching(true);
router.refresh();
}
};
const createSurveyButton = (
<Button size="sm" asChild>
<Link href={`/environments/${environment.id}/surveys/templates`}>
{t("environments.surveys.new_survey")}
<PlusIcon />
</Link>
</Button>
);
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>
);
}
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>
);
}
const triggerRefresh = useCallback(() => {
setRefreshTrigger((prev) => !prev);
}, []);
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>
<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}
/>
);
})}
</div>
{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>
)}
</div>
);
};
@@ -1,163 +0,0 @@
/**
* @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
);
});
});
@@ -1,34 +0,0 @@
"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() });
},
});
};
@@ -1,238 +0,0 @@
/**
* @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"));
});
});
@@ -1,50 +0,0 @@
"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,7 +1,8 @@
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { TSurveyFilters } from "@formbricks/types/surveys/types";
export const initialFilters: TSurveyOverviewFilters = {
export const initialFilters: TSurveyFilters = {
name: "",
createdBy: [],
status: [],
type: [],
sortBy: "relevance",
@@ -1,66 +0,0 @@
import type { InfiniteData } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import { flattenSurveyPages, removeSurveyFromInfiniteData } from "./query";
import { TSurveyListPage } from "./v3-surveys-client";
const surveyA = {
id: "survey_a",
name: "Survey A",
workspaceId: "env_1",
type: "link" as const,
status: "draft" as const,
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "Alice" },
singleUse: null,
};
const surveyB = {
...surveyA,
id: "survey_b",
name: "Survey B",
};
const baseData: InfiniteData<TSurveyListPage> = {
pages: [
{
data: [surveyA],
meta: {
limit: 20,
nextCursor: "cursor-1",
totalCount: 2,
},
},
{
data: [surveyB],
meta: {
limit: 20,
nextCursor: null,
totalCount: 2,
},
},
],
pageParams: [null, "cursor-1"],
};
describe("flattenSurveyPages", () => {
test("flattens every fetched page", () => {
expect(flattenSurveyPages(baseData)).toEqual([surveyA, surveyB]);
});
});
describe("removeSurveyFromInfiniteData", () => {
test("removes the survey from cached pages and decrements each page total", () => {
const nextData = removeSurveyFromInfiniteData(baseData, "survey_a");
expect(nextData?.pages[0]?.data).toEqual([]);
expect(nextData?.pages[1]?.data).toEqual([surveyB]);
expect(nextData?.pages[0]?.meta.totalCount).toBe(1);
expect(nextData?.pages[1]?.meta.totalCount).toBe(1);
});
test("returns the original cache when the survey is not present", () => {
expect(removeSurveyFromInfiniteData(baseData, "missing_survey")).toBe(baseData);
});
});
-57
View File
@@ -1,57 +0,0 @@
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,6 +17,7 @@ import { TProjectWithLanguages, TSurvey } from "../types/surveys";
// Import the module to be tested
import {
copySurveyToOtherEnvironment,
deleteSurvey,
getSurvey,
getSurveyCount,
getSurveys,
@@ -419,6 +420,57 @@ 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,6 +145,53 @@ 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: {
+60 -90
View File
@@ -1,98 +1,68 @@
import { describe, expect, test } from "vitest";
import { hasActiveSurveyFilters, normalizeSurveyFilters, parseStoredSurveyFilters } from "./utils";
import type { TSurveyFilters } from "@formbricks/types/surveys/types";
import { getFormattedFilters } from "./utils";
describe("normalizeSurveyFilters", () => {
test("returns the normalized default filters when input is empty", () => {
expect(normalizeSurveyFilters(undefined)).toEqual({
name: "",
status: [],
type: [],
sortBy: "relevance",
});
describe("getFormattedFilters", () => {
test("returns empty object when no filters provided", () => {
const result = getFormattedFilters({} as TSurveyFilters, "user1");
expect(result).toEqual({});
});
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"],
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"] },
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);
});
});
+20 -67
View File
@@ -1,77 +1,30 @@
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";
import { TSurveyFilterCriteria, TSurveyFilters } from "@formbricks/types/surveys/types";
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);
export const getFormattedFilters = (surveyFilters: TSurveyFilters, userId: string): TSurveyFilterCriteria => {
const filters: TSurveyFilterCriteria = {};
function getNormalizedStatus(value: unknown): TSurveyOverviewFilters["status"] {
if (!Array.isArray(value)) {
return [];
if (surveyFilters.name) {
filters.name = surveyFilters.name;
}
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.status && surveyFilters.status.length) {
filters.status = surveyFilters.status;
}
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.type && surveyFilters.type.length) {
filters.type = surveyFilters.type;
}
try {
return normalizeSurveyFilters(
JSON.parse(storedValue) as Partial<TSurveyOverviewFilters>,
currentProjectChannel
);
} catch {
return null;
if (surveyFilters.createdBy && surveyFilters.createdBy.length) {
filters.createdBy = {
userId: userId,
value: surveyFilters.createdBy,
};
}
}
export function hasActiveSurveyFilters(filters: TSurveyOverviewFilters): boolean {
return Boolean(filters.name) || filters.status.length > 0 || filters.type.length > 0;
}
if (surveyFilters.sortBy) {
filters.sortBy = surveyFilters.sortBy;
}
return filters;
};
@@ -1,22 +0,0 @@
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"
);
});
});
@@ -1,121 +0,0 @@
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;
}
+60 -12
View File
@@ -1,4 +1,6 @@
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";
@@ -9,6 +11,11 @@ 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",
@@ -37,24 +44,65 @@ 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,
};
return (
<SurveysList
environment={environment}
project={projectWithRequiredProps}
isReadOnly={isReadOnly}
publicDomain={publicDomain}
userId={session.user.id}
surveysPerPage={SURVEYS_PER_PAGE}
currentProjectChannel={currentProjectChannel}
locale={locale}
/>
);
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>;
};
@@ -1,39 +0,0 @@
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,6 +26,17 @@ 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">[];
}
@@ -67,15 +67,6 @@ describe("File Input Utils", () => {
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("File exceeds 5 MB size limit."));
});
test("should show size error for oversized files even when mime type is empty", async () => {
const files = [new File(["x".repeat(101000)], "large.ico", { type: "" })];
const result = await getAllowedFiles(files, ["ico"], 0.1);
expect(result).toHaveLength(0);
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("File exceeds 0.1 MB size limit."));
});
test("should convert HEIC files to JPEG", async () => {
const heicFile = new File(["test"], "test.heic", { type: "image/heic" });
const mockConvertedFile = new File(["converted"], "test.jpg", { type: "image/jpeg" });
@@ -21,7 +21,7 @@ export const getAllowedFiles = async (
const convertedFiles: File[] = [];
for (const file of files) {
if (!file) {
if (!file || !file.type) {
continue;
}
@@ -79,14 +79,14 @@ export const TagsCombobox = ({
return 0;
}}>
<div className="px-1 pt-1">
<div className="p-1">
<CommandInput
placeholder={
tagsToSearch?.length === 0
? t("environments.workspace.tags.add_tag")
: t("environments.workspace.tags.search_tags")
}
className="h-8 border-b border-none border-transparent py-1 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
className="border-b border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
value={searchValue}
onValueChange={(search) => setSearchValue(search)}
onKeyDown={(e) => {
@@ -103,7 +103,7 @@ export const TagsCombobox = ({
/>
</div>
<CommandList className="border-0">
<CommandGroup className="p-0">
<CommandGroup>
{tagsToSearch?.map((tag) => {
return (
<CommandItem
+3 -4
View File
@@ -74,7 +74,6 @@
"@t3-oss/env-nextjs": "0.13.10",
"@tailwindcss/forms": "0.5.11",
"@tailwindcss/typography": "0.5.19",
"@tanstack/react-query": "5.99.0",
"@tanstack/react-table": "8.21.3",
"@ungap/structured-clone": "1.3.0",
"bcryptjs": "3.0.3",
@@ -95,14 +94,14 @@
"jiti": "2.6.1",
"jsonwebtoken": "9.0.3",
"lexical": "0.41.0",
"lodash": "4.18.1",
"lodash": "4.17.23",
"lucide-react": "0.577.0",
"markdown-it": "14.1.1",
"next": "16.1.7",
"next-auth": "4.24.13",
"next-safe-action": "8.1.8",
"node-fetch": "3.3.2",
"nodemailer": "8.0.4",
"nodemailer": "8.0.2",
"otplib": "12.0.1",
"papaparse": "5.5.3",
"posthog-js": "1.360.0",
@@ -154,7 +153,7 @@
"dotenv": "17.3.1",
"postcss": "8.5.8",
"resize-observer-polyfill": "1.5.1",
"vite": "7.3.2",
"vite": "7.3.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.0.18",
"vitest-mock-extended": "3.1.0"
-393
View File
@@ -1,393 +0,0 @@
import { expect } from "@playwright/test";
import { prisma } from "@formbricks/database";
import { test } from "./lib/fixtures";
const SURVEYS_PER_PAGE = 12;
const getUserIdForEmail = async (email: string) => {
const user = await prisma.user.findUnique({
where: {
email,
},
select: {
id: true,
},
});
if (!user) {
throw new Error(`User not found for email: ${email}`);
}
return user.id;
};
const getWorkspaceIdsForEmail = async (email: string) => {
const user = await prisma.user.findUnique({
where: {
email,
},
select: {
id: true,
memberships: {
select: {
organizationId: true,
organization: {
select: {
projects: {
select: {
id: true,
environments: {
where: {
type: "development",
},
select: {
id: true,
},
take: 1,
},
},
take: 1,
},
},
},
},
take: 1,
},
},
});
const membership = user?.memberships[0];
const project = membership?.organization.projects[0];
const environment = project?.environments[0];
if (!user || !membership || !project || !environment) {
throw new Error(`Workspace not found for email: ${email}`);
}
return {
userId: user.id,
organizationId: membership.organizationId,
projectId: project.id,
environmentId: environment.id,
};
};
const createSurveySeed = async ({
environmentId,
userId,
name,
status = "draft",
type = "link",
}: {
environmentId: string;
userId: string;
name: string;
status?: "draft" | "inProgress" | "paused" | "completed";
type?: "link" | "app";
}) => {
return prisma.survey.create({
data: {
environmentId,
createdBy: userId,
name,
status,
type,
},
});
};
test.describe("Survey overview", () => {
test("loads surveys, applies filters and sort, and paginates with load more", async ({ page, users }) => {
const timestamp = Date.now();
const email = `overview-v3-${timestamp}@example.com`;
const name = `overview-v3-${timestamp}`;
const targetSurveyName = `Target Survey ${timestamp}`;
const pausedSurveyName = `Paused Survey ${timestamp}`;
const appSurveyName = `App Survey ${timestamp}`;
const paginatedSurveyName = `Paginated Survey ${timestamp}`;
const user = await users.create({
email,
name,
projectName: "Overview Workspace",
});
const userId = await getUserIdForEmail(email);
await user.login();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
const environmentId =
/\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ??
(() => {
throw new Error("Unable to determine environment id from surveys URL");
})();
const surveyDefinitions: Array<{
name: string;
status: "draft" | "paused";
type: "link" | "app";
}> = [
{ name: paginatedSurveyName, status: "draft", type: "link" },
{ name: "Overview Survey 01", status: "draft", type: "link" },
{ name: "Overview Survey 02", status: "paused", type: "link" },
{ name: "Overview Survey 03", status: "draft", type: "link" },
{ name: "Overview Survey 04", status: "paused", type: "link" },
{ name: "Overview Survey 05", status: "draft", type: "link" },
{ name: "Overview Survey 06", status: "paused", type: "link" },
{ name: "Overview Survey 07", status: "draft", type: "link" },
{ name: "Overview Survey 08", status: "paused", type: "link" },
{ name: "Overview Survey 09", status: "draft", type: "link" },
{ name: appSurveyName, status: "draft", type: "app" },
{ name: pausedSurveyName, status: "paused", type: "link" },
{ name: targetSurveyName, status: "draft", type: "link" },
];
for (const surveyDefinition of surveyDefinitions) {
await createSurveySeed({
environmentId,
userId,
name: surveyDefinition.name,
status: surveyDefinition.status,
type: surveyDefinition.type,
});
}
await page.reload();
await expect(page.locator(".surveyFilterDropdown").filter({ hasText: "Created by" })).toHaveCount(0);
await expect(page.getByText(targetSurveyName, { exact: true })).toBeVisible({ timeout: 10000 });
await expect(page.getByText(pausedSurveyName, { exact: true })).toBeVisible();
await expect(page.getByText(appSurveyName, { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Load more" })).toBeVisible();
await page.getByPlaceholder("Search by survey name").fill(targetSurveyName);
await expect(page.getByText(targetSurveyName, { exact: true })).toBeVisible();
await expect(page.getByText(pausedSurveyName, { exact: true })).toHaveCount(0);
await expect(page.getByText(appSurveyName, { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "Clear filters" }).click();
await expect(page.getByText(pausedSurveyName, { exact: true })).toBeVisible();
await page.locator(".surveyFilterDropdown").filter({ hasText: "Status" }).click();
await page.getByRole("menuitem", { name: "Draft" }).click();
await page.keyboard.press("Escape");
await expect(page.getByText(targetSurveyName, { exact: true })).toBeVisible();
await expect(page.getByText(pausedSurveyName, { exact: true })).toHaveCount(0);
await page.locator(".surveyFilterDropdown").filter({ hasText: "Type" }).click();
await page.getByRole("menuitem", { name: "Link" }).click();
await page.keyboard.press("Escape");
await expect(page.getByText(appSurveyName, { exact: true })).toHaveCount(0);
await page.locator(".surveyFilterDropdown").filter({ hasText: "Sort by" }).click();
await page.getByRole("menuitem", { name: "Created at" }).click();
await expect(
page.locator(".surveyFilterDropdown").filter({ hasText: "Sort by: Created at" })
).toBeVisible();
await page.getByRole("button", { name: "Clear filters" }).click();
await expect(page.getByText(appSurveyName, { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Load more" })).toBeVisible();
await page.getByRole("button", { name: "Load more" }).click();
await expect(page.getByText(paginatedSurveyName, { exact: true })).toBeVisible();
});
test("keeps draft-only actions and optimistically deletes the last survey into the template state", async ({
page,
users,
}) => {
const timestamp = Date.now();
const email = `overview-delete-${timestamp}@example.com`;
const name = `overview-delete-${timestamp}`;
const surveyName = `Delete Me ${timestamp}`;
const user = await users.create({
email,
name,
projectName: "Delete Workspace",
});
const userId = await getUserIdForEmail(email);
await user.login();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
const environmentId =
/\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ??
(() => {
throw new Error("Unable to determine environment id from surveys URL");
})();
const survey = await createSurveySeed({
environmentId,
userId,
name: surveyName,
status: "draft",
type: "link",
});
await page.reload();
await expect(page.getByText(surveyName, { exact: true })).toBeVisible({ timeout: 10000 });
let releaseDeleteRequest: (() => void) | undefined;
let resolveDeleteStarted: (() => void) | undefined;
const deleteStarted = new Promise<void>((resolve) => {
resolveDeleteStarted = resolve;
});
await page.route(`**/api/v3/surveys/${survey.id}`, async (route) => {
resolveDeleteStarted?.();
await new Promise<void>((resolveDelete) => {
releaseDeleteRequest = resolveDelete;
});
await route.continue();
});
await page.locator("[data-testid='survey-dropdown-trigger']").click();
await expect(page.getByText("Duplicate", { exact: true })).toHaveCount(0);
await expect(page.getByText("Copy...", { exact: true })).toHaveCount(0);
await expect(page.getByText("Preview", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("copy-link")).toHaveCount(0);
await page.getByRole("button", { name: "Delete", exact: true }).click();
await page.getByRole("dialog").getByRole("button", { name: "Delete", exact: true }).click();
await deleteStarted;
await expect(page.getByText(surveyName, { exact: true })).toBeHidden();
await expect(page.getByText("Start from scratch", { exact: true })).toBeVisible();
releaseDeleteRequest?.();
await expect(page.getByText("Survey deleted successfully", { exact: true })).toBeVisible();
});
test("restores the survey when delete fails", async ({ page, users }) => {
const timestamp = Date.now();
const email = `overview-delete-failure-${timestamp}@example.com`;
const name = `overview-delete-failure-${timestamp}`;
const surveyName = `Rollback Me ${timestamp}`;
const user = await users.create({
email,
name,
projectName: "Rollback Workspace",
});
const userId = await getUserIdForEmail(email);
await user.login();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
const environmentId =
/\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ??
(() => {
throw new Error("Unable to determine environment id from surveys URL");
})();
const survey = await createSurveySeed({
environmentId,
userId,
name: surveyName,
status: "draft",
type: "link",
});
await page.reload();
await expect(page.getByText(surveyName, { exact: true })).toBeVisible({ timeout: 10000 });
await page.route(`**/api/v3/surveys/${survey.id}`, async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
await route.fulfill({
status: 500,
contentType: "application/problem+json",
body: JSON.stringify({
title: "Internal Server Error",
status: 500,
detail: "Delete failed",
requestId: "playwright-delete-failure",
}),
});
});
await page.locator("[data-testid='survey-dropdown-trigger']").click();
await page.getByRole("button", { name: "Delete", exact: true }).click();
await page.getByRole("dialog").getByRole("button", { name: "Delete", exact: true }).click();
await expect(page.getByText(surveyName, { exact: true })).toBeHidden();
await expect(page.getByText(surveyName, { exact: true })).toBeVisible();
await expect(page.getByText("Delete failed", { exact: true })).toBeVisible();
});
test("shows preview and copy link for published link surveys and hides the dropdown when no actions are available", async ({
page,
users,
}) => {
const timestamp = Date.now();
const email = `overview-actions-${timestamp}@example.com`;
const name = `overview-actions-${timestamp}`;
const linkSurveyName = `Published Link ${timestamp}`;
const appSurveyName = `Read Only App ${timestamp}`;
const user = await users.create({
email,
name,
projectName: "Action Workspace",
});
const { userId, organizationId, projectId, environmentId } = await getWorkspaceIdsForEmail(email);
await prisma.membership.update({
where: {
userId_organizationId: {
userId,
organizationId,
},
},
data: {
role: "member",
},
});
const team = await prisma.team.create({
data: {
name: `Read Only Team ${timestamp}`,
organizationId,
},
select: {
id: true,
},
});
await prisma.teamUser.create({
data: {
teamId: team.id,
userId,
role: "contributor",
},
});
await prisma.projectTeam.create({
data: {
teamId: team.id,
projectId,
permission: "read",
},
});
await user.login();
await page.goto(`/environments/${environmentId}/surveys`);
await createSurveySeed({
environmentId,
userId,
name: linkSurveyName,
status: "paused",
type: "link",
});
await createSurveySeed({
environmentId,
userId,
name: appSurveyName,
status: "completed",
type: "app",
});
await page.reload();
const linkRow = page.locator("div.relative.block", {
has: page.getByText(linkSurveyName, { exact: true }),
});
await linkRow.locator("[data-testid='survey-dropdown-trigger']").click();
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
await expect(page.getByTestId("copy-link")).toBeVisible();
const appRow = page.locator("div.relative.block", {
has: page.getByText(appSurveyName, { exact: true }),
});
await expect(appRow.locator("[data-testid='survey-dropdown-trigger']")).toHaveCount(0);
});
});
+6 -91
View File
@@ -1,12 +1,12 @@
# V3 API — Surveys (hand-maintained; not generated by generate-api-specs).
# Implementation: apps/web/app/api/v3/surveys/route.ts and apps/web/app/api/v3/surveys/[surveyId]/route.ts
# V3 API — GET Surveys (hand-maintained; not generated by generate-api-specs).
# Implementation: apps/web/app/api/v3/surveys/route.ts
# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context.
openapi: 3.1.0
info:
title: Formbricks API v3
description: |
**GET /api/v3/surveys** and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace environment).
**GET /api/v3/surveys** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace environment).
**Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
@@ -28,11 +28,8 @@ info:
**OpenAPI**
This YAML is **not** produced by `pnpm generate-api-specs` (that script only builds v2 → `docs/api-v2-reference/openapi.yml`). Update this file when the route contract changes.
**Overview migration note**
The v3-backed survey overview page intentionally removes actions that are not yet exposed by this contract: `Created by` filtering, `Duplicate`, `Copy...`, `Preview`, and `Copy link`.
**Next steps (out of scope for this spec)**
Additional v3 survey endpoints, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
Additional v3 survey endpoints (single survey, CRUD), frontend cutover from `getSurveysAction`, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
version: 0.1.0
x-implementation-notes:
route: apps/web/app/api/v3/surveys/route.ts
@@ -176,72 +173,6 @@ paths:
security:
- sessionAuth: []
- apiKeyAuth: []
/api/v3/surveys/{surveyId}:
delete:
operationId: deleteSurveyV3
summary: Delete a survey
description: Deletes a survey by id. Session cookie or x-api-key.
tags:
- V3 Surveys
parameters:
- in: path
name: surveyId
required: true
schema:
type: string
format: cuid2
description: Survey identifier.
responses:
"200":
description: Survey deleted successfully
headers:
X-Request-Id:
schema: { type: string }
description: Request correlation ID
Cache-Control:
schema: { type: string }
example: "private, no-store"
content:
application/json:
schema:
$ref: "#/components/schemas/SurveyDeleteResponse"
"400":
description: Bad Request
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"401":
description: Not authenticated (no valid session or API key)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"403":
description: Forbidden — no access, or survey does not exist (404 not used; avoids existence leak)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"429":
description: Rate limit exceeded
headers:
Retry-After:
schema: { type: integer }
description: Seconds until the current rate-limit window resets
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"500":
description: Internal Server Error
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
security:
- sessionAuth: []
- apiKeyAuth: []
components:
securitySchemes:
@@ -262,13 +193,12 @@ components:
SurveyListItem:
type: object
description: |
Shape returned by `GET /api/v3/surveys`. Serialized dates are ISO 8601 strings.
The v3 overview contract intentionally omits `environmentId` and internal fields such as `_count`.
Shape from `getSurveys` (`surveySelect` + `responseCount`). Serialized dates are ISO 8601 strings.
Legacy DB rows may include survey **type** values `website` or `web` (see Prisma); filter **type** only accepts `link` | `app`.
properties:
id: { type: string }
name: { type: string }
workspaceId: { type: string }
environmentId: { type: string }
type: { type: string, enum: [link, app, website, web] }
status:
type: string
@@ -277,21 +207,6 @@ components:
updatedAt: { type: string, format: date-time }
responseCount: { type: integer }
creator: { type: object, nullable: true, properties: { name: { type: string } } }
singleUse:
type: object
nullable: true
properties:
enabled: { type: boolean }
isEncrypted: { type: boolean }
SurveyDeleteResponse:
type: object
required: [data]
properties:
data:
type: object
required: [id]
properties:
id: { type: string }
Problem:
type: object
description: RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`.
+1 -1
View File
@@ -48,7 +48,7 @@
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@vitest/coverage-v8": "4.0.18",
"vite": "8.0.5",
"vite": "8.0.0",
"vite-plugin-dts": "4.5.4",
"vitest": "4.0.18"
}
+1 -1
View File
@@ -44,7 +44,7 @@
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"vite": "7.3.2",
"vite": "7.3.1",
"vitest": "4.0.18",
"@vitest/coverage-v8": "4.0.18"
}
+1 -1
View File
@@ -63,7 +63,7 @@
"prisma": "6.19.2",
"prisma-json-types-generator": "3.6.2",
"tsx": "4.21.0",
"vite": "7.3.2",
"vite": "7.3.1",
"vite-plugin-dts": "4.5.4"
}
}
+1 -1
View File
@@ -42,7 +42,7 @@
"@formbricks/eslint-config": "workspace:*",
"@types/node": "^25.4.0",
"tsx": "^4.21.0",
"vite": "7.3.2",
"vite": "7.3.1",
"vite-plugin-dts": "4.5.4",
"vitest": "4.0.18"
}
+1 -1
View File
@@ -47,7 +47,7 @@
"@formbricks/eslint-config": "workspace:*",
"@vitest/coverage-v8": "4.0.18",
"terser": "5.46.0",
"vite": "7.3.2",
"vite": "7.3.1",
"vite-plugin-dts": "4.5.4",
"vitest": "4.0.18"
}
+1 -1
View File
@@ -41,7 +41,7 @@
"pino-pretty": "13.1.3"
},
"devDependencies": {
"vite": "7.3.2",
"vite": "7.3.1",
"@formbricks/config-typescript": "workspace:*",
"vitest": "4.0.18",
"@formbricks/eslint-config": "workspace:*",
+1 -1
View File
@@ -44,7 +44,7 @@
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"vite": "7.3.2",
"vite": "7.3.1",
"vite-plugin-dts": "4.5.4",
"vitest": "4.0.18",
"@vitest/coverage-v8": "4.0.18"
+1 -1
View File
@@ -96,7 +96,7 @@
"react-dom": "19.2.4",
"rimraf": "6.1.3",
"tailwindcss": "4.2.1",
"vite": "7.3.2",
"vite": "7.3.1",
"vite-plugin-dts": "4.5.4",
"vite-tsconfig-paths": "6.1.1",
"@vitest/coverage-v8": "4.0.18",
@@ -78,58 +78,6 @@ interface SingleSelectProps {
searchNoResultsText?: string;
}
const useDropdownCommitState = ({
variant,
selectedValue,
onChange,
handleDropdownOpen,
handleDropdownClose,
}: {
variant: "list" | "dropdown";
selectedValue: string | undefined;
onChange: (value: string) => void;
handleDropdownOpen: () => void;
handleDropdownClose: () => void;
}) => {
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
const [pendingDropdownValue, setPendingDropdownValue] = React.useState<string | undefined>(selectedValue);
const [hasPendingDropdownChange, setHasPendingDropdownChange] = React.useState(false);
React.useEffect(() => {
if (!isDropdownOpen) {
setPendingDropdownValue(selectedValue);
setHasPendingDropdownChange(false);
}
}, [selectedValue, isDropdownOpen]);
const handleDropdownOpenChange = (open: boolean) => {
setIsDropdownOpen(open);
if (open) {
setPendingDropdownValue(selectedValue);
setHasPendingDropdownChange(false);
handleDropdownOpen();
return;
}
handleDropdownClose();
if (hasPendingDropdownChange && pendingDropdownValue && pendingDropdownValue !== selectedValue) {
onChange(pendingDropdownValue);
}
setHasPendingDropdownChange(false);
};
const effectiveSelectedValue =
variant === "dropdown" && isDropdownOpen ? pendingDropdownValue : selectedValue;
return {
effectiveSelectedValue,
handleDropdownOpenChange,
setPendingDropdownValue,
setHasPendingDropdownChange,
};
};
// NOSONAR - This component intentionally keeps list/dropdown rendering in one place for consistent a11y behavior.
function SingleSelect({
elementId,
headline,
@@ -180,19 +128,6 @@ function SingleSelect({
handleDropdownClose,
} = useDropdownSearch({ options, hasOtherOption, otherOptionLabel, isSearchEnabled: showSearch });
const {
effectiveSelectedValue,
handleDropdownOpenChange,
setPendingDropdownValue,
setHasPendingDropdownChange,
} = useDropdownCommitState({
variant,
selectedValue,
onChange,
handleDropdownOpen,
handleDropdownClose,
});
React.useEffect(() => {
if (!isOtherSelected || disabled) return;
@@ -227,7 +162,7 @@ function SingleSelect({
const optionLabelClassName = "font-option text-option font-option-weight text-option-label";
// Get selected option label for dropdown display
const selectedOption = options.find((opt) => opt.id === effectiveSelectedValue);
const selectedOption = options.find((opt) => opt.id === selectedValue);
const displayText = isOtherSelected
? otherValue || otherOptionLabel
: (selectedOption?.label ?? placeholder);
@@ -250,7 +185,11 @@ function SingleSelect({
{variant === "dropdown" ? (
<>
<ElementError errorMessage={errorMessage} dir={dir} />
<DropdownMenu onOpenChange={handleDropdownOpenChange}>
<DropdownMenu
onOpenChange={(open) => {
if (open) handleDropdownOpen();
else handleDropdownClose();
}}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
@@ -278,12 +217,7 @@ function SingleSelect({
/>
) : null}
<div className="max-h-[260px] overflow-y-auto">
<DropdownMenuRadioGroup
value={effectiveSelectedValue}
onValueChange={(newValue) => {
setPendingDropdownValue(newValue);
setHasPendingDropdownChange(newValue !== selectedValue);
}}>
<DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}>
{filteredRegularOptions.map((option) => {
const optionId = `${inputId}-${option.id}`;
@@ -20,14 +20,12 @@ function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownM
function DropdownMenuContent({
className,
sideOffset = 4,
ref,
...props
}: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Content>>) {
return (
<DropdownMenuPrimitive.Portal>
<div id="fbjs">
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
@@ -58,9 +58,7 @@ export function useDropdownSearch<T extends { id: string; label: string }>({
const focusSearchAndLockSide = (): void => {
searchInputRef.current?.focus();
const dataset = contentRef.current?.dataset;
if (!dataset) return;
const side = dataset.side;
const side = contentRef.current?.dataset.side;
if (side === "top" || side === "bottom") setLockedSide(side);
};
-2
View File
@@ -7,12 +7,10 @@ checksums:
common/back: f541015a827e37cb3b1234e56bc2aa3c
common/close_survey: 36e6aaa19051cb253aa155ad69a9edbc
common/company_logo: 82d5c0d5994508210ee02d684819f4b8
common/failed_to_load_booking_widget: 6fcdeae283dc6c08cc8186c3751ecf24
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/language_switch: fd72a9ada13f672f4fd5da863b22cc46
common/next: 89ddbcf710eba274963494f312bdc8a9
common/no_results_found: 5518f2865757dc73900aa03ef8be6934
common/open_booking_page_directly_at: 5f51eb388be802279f52b0eda32985e7
common/open_in_new_tab: 6844e4922a7a40a7ee25c10ea109cdeb
common/people_responded: b685fb877090d8658db724ad07a0dbd8
common/please_retry_now_or_try_again_later: 949a3841e2eb01fa249790a42bf23aa5
-2
View File
@@ -6,12 +6,10 @@
"back": "رجوع",
"close_survey": "إغلاق الاستبيان",
"company_logo": "شعار الشركة",
"failed_to_load_booking_widget": "فشل تحميل نافذة الحجز. قد تكون بيئتك تحظر الموارد عبر النطاقات.",
"finish": "إنهاء",
"language_switch": "تبديل اللغة",
"next": "التالي",
"no_results_found": "لم يتم العثور على نتائج",
"open_booking_page_directly_at": "جرب فتح صفحة الحجز مباشرة على",
"open_in_new_tab": "فتح في علامة تبويب جديدة",
"people_responded": "{count, plural, one {شخص واحد استجاب} two {شخصان استجابا} few {{count} أشخاص استجابوا} many {{count} شخصًا استجابوا} other {{count} شخص استجابوا}}",
"please_retry_now_or_try_again_later": "يرجى إعادة المحاولة الآن أو المحاولة مرة أخرى لاحقًا.",
-2
View File
@@ -6,12 +6,10 @@
"back": "Tilbage",
"close_survey": "Luk undersøgelse",
"company_logo": "Firmalogo",
"failed_to_load_booking_widget": "Kunne ikke indlæse bookingwidget. Dit miljø blokerer muligvis ressourcer på tværs af domæner.",
"finish": "Afslut",
"language_switch": "Sprogskift",
"next": "Næste",
"no_results_found": "Ingen resultater fundet",
"open_booking_page_directly_at": "Prøv at åbne bookingsiden direkte på",
"open_in_new_tab": "Åbn i ny fane",
"people_responded": "{count, plural, one {1 person har svaret} other {{count} personer har svaret}}",
"please_retry_now_or_try_again_later": "Prøv igen nu eller prøv senere.",
-2
View File
@@ -6,12 +6,10 @@
"back": "Zurück",
"close_survey": "Umfrage schließen",
"company_logo": "Firmenlogo",
"failed_to_load_booking_widget": "Das Buchungs-Widget konnte nicht geladen werden. Deine Umgebung blockiert möglicherweise Cross-Origin-Ressourcen.",
"finish": "Fertig",
"language_switch": "Sprachwechsel",
"next": "Weiter",
"no_results_found": "Keine Ergebnisse gefunden",
"open_booking_page_directly_at": "Versuch, die Buchungsseite direkt zu öffnen unter",
"open_in_new_tab": "In neuem Tab öffnen",
"people_responded": "{count, plural, one {1 Person hat geantwortet} other {{count} Personen haben geantwortet}}",
"please_retry_now_or_try_again_later": "Bitte versuchen Sie es jetzt erneut oder später noch einmal.",
-2
View File
@@ -6,12 +6,10 @@
"back": "Back",
"close_survey": "Close survey",
"company_logo": "Company Logo",
"failed_to_load_booking_widget": "Failed to load booking widget. Your environment may be blocking cross-origin resources.",
"finish": "Finish",
"language_switch": "Language switch",
"next": "Next",
"no_results_found": "No results found",
"open_booking_page_directly_at": "Try opening the booking page directly at",
"open_in_new_tab": "Open in new tab",
"people_responded": "{count, plural, one {1 person responded} other {{count} people responded}}",
"please_retry_now_or_try_again_later": "Please retry now or try again later.",
-2
View File
@@ -6,12 +6,10 @@
"back": "Atrás",
"close_survey": "Cerrar encuesta",
"company_logo": "Logo de la empresa",
"failed_to_load_booking_widget": "No se pudo cargar el widget de reservas. Tu entorno puede estar bloqueando recursos de origen cruzado.",
"finish": "Finalizar",
"language_switch": "Cambio de idioma",
"next": "Siguiente",
"no_results_found": "No se encontraron resultados",
"open_booking_page_directly_at": "Intenta abrir la página de reservas directamente en",
"open_in_new_tab": "Abrir en nueva pestaña",
"people_responded": "{count, plural, one {1 persona respondió} other {{count} personas respondieron}}",
"please_retry_now_or_try_again_later": "Por favor, inténtalo ahora o prueba más tarde.",
-2
View File
@@ -6,12 +6,10 @@
"back": "Tagasi",
"close_survey": "Sulge küsitlus",
"company_logo": "Ettevõtte logo",
"failed_to_load_booking_widget": "Broneerimisvidina laadimine ebaõnnestus. Sinu keskkond võib blokeerida ristdomeenide ressursse.",
"finish": "Lõpeta",
"language_switch": "Keele vahetamine",
"next": "Edasi",
"no_results_found": "Tulemusi ei leitud",
"open_booking_page_directly_at": "Proovi avada broneerimislehte otse aadressil",
"open_in_new_tab": "Ava uuel vahelehel",
"people_responded": "{count, plural, one {1 inimene vastas} other {{count} inimest vastas}}",
"please_retry_now_or_try_again_later": "Palun proovi uuesti kohe või hiljem.",
-2
View File
@@ -6,12 +6,10 @@
"back": "Retour",
"close_survey": "Fermer le sondage",
"company_logo": "Logo de l'entreprise",
"failed_to_load_booking_widget": "Échec du chargement du widget de réservation. Votre environnement bloque peut-être les ressources d'origine croisée.",
"finish": "Terminer",
"language_switch": "Changement de langue",
"next": "Suivant",
"no_results_found": "Aucun résultat trouvé",
"open_booking_page_directly_at": "Essayez d'ouvrir la page de réservation directement à l'adresse",
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
"people_responded": "{count, plural, one {1 personne a répondu} other {{count} personnes ont répondu}}",
"please_retry_now_or_try_again_later": "Veuillez réessayer maintenant ou réessayer plus tard.",
-2
View File
@@ -6,12 +6,10 @@
"back": "वापस",
"close_survey": "सर्वेक्षण बंद करें",
"company_logo": "कंपनी लोगो",
"failed_to_load_booking_widget": "बुकिंग विजेट लोड करने में विफल। आपका एनवायरनमेंट क्रॉस-ओरिजिन संसाधनों को ब्लॉक कर रहा है।",
"finish": "समाप्त करें",
"language_switch": "भाषा बदलें",
"next": "अगला",
"no_results_found": "कोई परिणाम नहीं मिला",
"open_booking_page_directly_at": "बुकिंग पेज को सीधे यहां खोलने का प्रयास करें",
"open_in_new_tab": "नए टैब में खोलें",
"people_responded": "{count, plural, one {1 व्यक्ति ने जवाब दिया} other {{count} लोगों ने जवाब दिया}}",
"please_retry_now_or_try_again_later": "कृपया अभी पुनः प्रयास करें या बाद में फिर से प्रयास करें।",
-2
View File
@@ -6,12 +6,10 @@
"back": "Vissza",
"close_survey": "Kérdőív lezárása",
"company_logo": "Vállalat logója",
"failed_to_load_booking_widget": "A foglalási modul betöltése sikertelen. Lehet, hogy a környezeted blokkolja a különböző forrásokból származó erőforrásokat.",
"finish": "Befejezés",
"language_switch": "Nyelvválasztó",
"next": "Következő",
"no_results_found": "Nincs találat",
"open_booking_page_directly_at": "Próbáld meg közvetlenül megnyitni a foglalási oldalt itt:",
"open_in_new_tab": "Megnyitás új lapon",
"people_responded": "{count, plural, one {1 személy válaszolt} other {{count} személy válaszolt}}",
"please_retry_now_or_try_again_later": "Próbálkozzon újra most, vagy próbálja meg később újra.",

Some files were not shown because too many files have changed in this diff Show More