Compare commits

..

14 Commits

Author SHA1 Message Date
Balázs Úr 41b88e46e6 update Hungarian translations 2026-04-20 10:58:00 +02:00
Johannes cefc2bdf60 fix: show oversized upload error when mime type is missing (#7757)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-20 07:00:41 +00:00
dependabot[bot] 78473bf3d0 chore(deps): bump the npm_and_yarn group across 12 directories with 4 updates (#7680)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-04-20 06:59:52 +00:00
Johannes 15403c6a92 fix: add accessible dialog title to project limit modal (#7769)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:45:21 +00:00
Johannes 35b98863a4 feat: auto-fill safe attribute key from label (#7771)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:44:10 +00:00
Anshuman Pandey 65f5968fb1 fix: fixes sentry ref issue (#7776) 2026-04-20 06:29:44 +00:00
Bhagya Amarasinghe 2dfea4d72f fix: prevent split offline responses on restore (#7767) 2026-04-20 06:05:13 +00:00
Dhruwang Jariwala ff77118932 fix: response tag UI issues in response modal (#7765) 2026-04-17 11:59:59 +00:00
Johannes 79a773432a feat: extend auto-progress to single-select question types (#7725) 2026-04-17 10:17:00 +00:00
Niels Kaspers d53869f1df fix: fix duplicate block and misleading subheader in trial conversion template (#7560)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 10:01:54 +00:00
Balázs Úr fc9ddb2b0d fix: mark Identify Customer Goals survey as translatable (#7566)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 09:53:15 +00:00
Bhagya Amarasinghe 6fcb6863bd feat: migrate survey overview to v3 APIs (#7741) 2026-04-17 09:45:12 +00:00
Johannes b1cee91ad9 fix: redirect active project and organization selections (#7724)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-17 09:33:12 +00:00
Balázs Úr eb1542db55 fix: Hungarian translation 2026-04-16 12:53:46 +02:00
107 changed files with 4857 additions and 1827 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.1",
"vite": "7.3.2",
"@storybook/addon-docs": "10.2.17"
}
}
@@ -409,16 +409,22 @@ export const MainNavigation = ({
: `/environments/${environment.id}/surveys/`;
const handleProjectChange = (projectId: string) => {
if (projectId === project.id) return;
const targetPath =
projectId === project.id ? `/environments/${environment.id}/surveys` : `/workspaces/${projectId}/`;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === organization.id) return;
const targetPath =
organizationId === organization.id
? `/environments/${environment.id}/settings/general`
: `/organizations/${organizationId}/`;
startTransition(() => {
router.push(`/organizations/${organizationId}/`);
setIsOrganizationDropdownOpen(false);
router.push(targetPath);
});
};
@@ -114,8 +114,12 @@ 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,9 +152,13 @@ export const ProjectBreadcrumb = ({
}
const handleProjectChange = (projectId: string) => {
if (projectId === currentProjectId) return;
const targetPath =
projectId === currentProjectId
? `/environments/${currentEnvironmentId}/surveys`
: `/workspaces/${projectId}/`;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
setIsProjectDropdownOpen(false);
router.push(targetPath);
});
};
@@ -0,0 +1,8 @@
import { type ReactNode } from "react";
import { SurveysQueryClientProvider } from "./query-client-provider";
const SurveysLayout = ({ children }: { children: ReactNode }) => {
return <SurveysQueryClientProvider>{children}</SurveysQueryClientProvider>;
};
export default SurveysLayout;
@@ -0,0 +1,10 @@
"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>;
};
@@ -0,0 +1,98 @@
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);
});
});
@@ -0,0 +1,44 @@
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;
}
};
@@ -0,0 +1,70 @@
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);
});
});
@@ -0,0 +1,40 @@
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,47 +1,19 @@
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";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
const { mockDeleteSharedSurvey } = vi.hoisted(() => ({
mockDeleteSharedSurvey: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
delete: vi.fn(),
},
segment: {
delete: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: mockDeleteSharedSurvey,
}));
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,
environmentId: "clq5n7p1q0000m7z0h5p6g3r3",
type: "link",
segment: null,
triggers: [],
@@ -56,66 +28,20 @@ describe("deleteSurvey", () => {
vi.clearAllMocks();
});
test("should delete a link survey without a segment and revalidate caches", async () => {
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any);
test("delegates survey deletion to the shared service", async () => {
mockDeleteSharedSurvey.mockResolvedValue(mockDeletedSurveyLink);
const deletedSurvey = await deleteSurvey(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(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
});
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 () => {
test("rethrows shared delete service errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.delete).mockRejectedValue(genericError);
mockDeleteSharedSurvey.mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
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();
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
});
});
@@ -1,43 +1,3 @@
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";
import { deleteSurvey as deleteSharedSurvey } from "@/modules/survey/lib/surveys";
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;
}
};
export const deleteSurvey = async (surveyId: string) => deleteSharedSurvey(surveyId);
@@ -1,5 +1,6 @@
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";
@@ -70,6 +71,12 @@ 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,6 +9,22 @@ 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,
}));
@@ -25,6 +41,14 @@ 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(() => ({
@@ -45,6 +69,114 @@ 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({
+76 -2
View File
@@ -4,10 +4,13 @@ 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,
@@ -15,7 +18,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3Authentication } from "./types";
import type { TV3AuditLog, TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
@@ -38,6 +41,7 @@ export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unkn
req: NextRequest;
props: TProps;
authentication: TV3Authentication;
auditLog?: TV3AuditLog;
parsedInput: TParsedInput;
requestId: string;
instance: string;
@@ -48,6 +52,8 @@ 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>;
};
@@ -293,10 +299,61 @@ 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 } = params;
const {
auth = "both",
schemas,
rateLimit = true,
customRateLimitConfig,
handler,
action,
targetType,
} = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
@@ -306,6 +363,7 @@ 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);
@@ -331,17 +389,33 @@ 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,6 +7,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
successListResponse,
successResponse,
} from "./response";
describe("v3 problem responses", () => {
@@ -93,3 +94,27 @@ 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,3 +147,27 @@ 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,4 +1,6 @@
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;
@@ -0,0 +1,321 @@
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,
}),
})
);
});
});
@@ -0,0 +1,72 @@
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" | "singleUse"> & {
export type TV3SurveyListItem = Omit<TSurvey, "environmentId"> & {
workspaceId: string;
};
@@ -9,7 +9,7 @@ export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
* Internally surveys are still scoped by environmentId; externally v3 exposes workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { environmentId, singleUse: _omitSingleUse, ...rest } = survey;
const { environmentId, ...rest } = survey;
return {
...rest,
+2 -2
View File
@@ -971,13 +971,13 @@ const improveTrialConversion = (t: TFunction): TTemplate => {
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.improve_trial_conversion_question_2_headline"),
headline: t("templates.improve_trial_conversion_question_3_headline"),
required: true,
inputType: "text",
}),
],
logic: [createBlockJumpLogic(reusableElementIds[2], block6Id, "isSubmitted")],
buttonLabel: t("templates.improve_trial_conversion_question_2_button_label"),
buttonLabel: t("templates.improve_trial_conversion_question_3_button_label"),
t,
}),
buildBlock({
+10 -8
View File
@@ -1296,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: cbf676789b9f3f47e36bdf35fa58282b
environments/surveys/edit/auto_progress_rating_and_nps_description: 2a992dd8a5b9532f178f9a21881feb9a
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
@@ -2194,12 +2194,12 @@ checksums:
environments/workspace/look/advanced_styling_field_track_bg_description: 8a56258273dfe49e83fe752ea9e8daed
environments/workspace/look/advanced_styling_field_track_height: 9ce57cb4583039c224a37e013efb6b8f
environments/workspace/look/advanced_styling_field_track_height_description: 90243a4374e15d9118ad0fd93d5f3614
environments/workspace/look/advanced_styling_field_upper_label_color: 896bc12d6c77f162abf189589e11287e
environments/workspace/look/advanced_styling_field_upper_label_color_description: 1bc4b92821ff694c18396a872ca0fb60
environments/workspace/look/advanced_styling_field_upper_label_size: 0d915cf63b845a0c4c6004efc731babf
environments/workspace/look/advanced_styling_field_upper_label_size_description: 8e9e348318b72837c9e04f81701b2fb6
environments/workspace/look/advanced_styling_field_upper_label_weight: 9128dcfea0e5b1e0fdf4f5902f6ea2d6
environments/workspace/look/advanced_styling_field_upper_label_weight_description: 3a9006a41ab84aea98d4291a6d5db9db
environments/workspace/look/advanced_styling_field_upper_label_color: 2767a5db32742073a01aac16488e93dc
environments/workspace/look/advanced_styling_field_upper_label_color_description: 58f43ce21b7f6539cc937aa80c7e8060
environments/workspace/look/advanced_styling_field_upper_label_size: 3342babd1df61a3bdf7a3284137f7c24
environments/workspace/look/advanced_styling_field_upper_label_size_description: 867a89a79ed7ac7f1c6b0f3481a67f26
environments/workspace/look/advanced_styling_field_upper_label_weight: a9a0de9e840518d282cfdbcb02d059b5
environments/workspace/look/advanced_styling_field_upper_label_weight_description: 3cee88e1c8e75548dcb6004f0e44f31c
environments/workspace/look/advanced_styling_section_buttons: 3b44d6e2800e7bf3f133f1bce435f4c2
environments/workspace/look/advanced_styling_section_headlines: 6def704c0ac2ecb5951400c806856a41
environments/workspace/look/advanced_styling_section_inputs: 76bbeb561122a72fd3ec8c49eff7c563
@@ -2811,12 +2811,14 @@ checksums:
templates/improve_trial_conversion_question_1_subheader: 67c7047ba2365d461df14dbed3f9506d
templates/improve_trial_conversion_question_2_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_2_headline: 05dd4820f60b9d267a9affc7e662f029
templates/improve_trial_conversion_question_3_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_3_headline: 3daeccf3dfc7bf8e9868c10fb3ea0b19
templates/improve_trial_conversion_question_4_button_label: d94a6a11cfdf4ebde4c5332e585e2e96
templates/improve_trial_conversion_question_4_headline: 9b07341f65574c4165086ec107cebb45
templates/improve_trial_conversion_question_4_html: 8ce95691eeeae7ad61c4d2f867b918ca
templates/improve_trial_conversion_question_5_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_5_headline: dbd99e216fcbf8693b8e77fbd77e1c84
templates/improve_trial_conversion_question_5_subheader: b9b478e967930358b0c74324a7c18fc8
templates/improve_trial_conversion_question_5_subheader: 859876a442a633f4aa0d78fd0ee4ab4c
templates/improve_trial_conversion_question_6_headline: f15239ecc4f1a6bd8bea77a38b39c844
templates/improve_trial_conversion_question_6_subheader: e147ddbb609fff6e6fc78fb1f4add0ac
templates/integration_setup_survey_description: 696ccab07d7098cdb79c224fa1208889
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { isSafeIdentifier } from "./safe-identifier";
import { isSafeIdentifier, toSafeIdentifier } from "./safe-identifier";
describe("safe-identifier", () => {
describe("isSafeIdentifier", () => {
@@ -32,4 +32,23 @@ 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,6 +12,44 @@ 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"
+7 -15
View File
@@ -342,7 +342,6 @@
"other": "Andere",
"other_filters": "Weitere Filter",
"other_placeholder": "Sonstiger Platzhalter",
"others": "Andere",
"overlay_color": "Overlay-Farbe",
"overview": "Überblick",
"password": "Passwort",
@@ -360,7 +359,6 @@
"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",
@@ -495,7 +493,6 @@
"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.",
@@ -1309,12 +1306,7 @@
"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",
@@ -1367,7 +1359,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": "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_progress_rating_and_nps_description": "Automatisches Weitergehen bei Einzelfragen-Blöcken. Pflichtfragen blenden Weiter aus, außer wenn \"Sonstiges\" ausgewählt ist.",
"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",
@@ -2066,7 +2058,6 @@
"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)",
@@ -2154,7 +2145,6 @@
},
"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",
@@ -2312,11 +2302,11 @@
"advanced_styling_field_track_height": "Track-Höhe",
"advanced_styling_field_track_height_description": "Steuert die Dicke des Fortschrittsbalkens.",
"advanced_styling_field_upper_label_color": "Labelfarbe",
"advanced_styling_field_upper_label_color_description": "Färbt die kleine Beschriftung über Eingabefeldern und Skalenbeschriftungen.",
"advanced_styling_field_upper_label_color_description": "Färbt die kleinen Labels über Eingabefeldern und Skalenbeschriftungen ein.",
"advanced_styling_field_upper_label_size": "Label-Schriftgröße",
"advanced_styling_field_upper_label_size_description": "Skaliert die kleine Beschriftung über Eingabefeldern und Skalenbeschriftungen.",
"advanced_styling_field_upper_label_size_description": "Skaliert die kleinen Labels über Eingabefeldern und Skalenbeschriftungen.",
"advanced_styling_field_upper_label_weight": "Label-Schriftstärke",
"advanced_styling_field_upper_label_weight_description": "Macht die Beschriftung leichter oder fetter.",
"advanced_styling_field_upper_label_weight_description": "Macht die Labels dünner oder fetter.",
"advanced_styling_section_buttons": "Buttons",
"advanced_styling_section_headlines": "Überschriften & Beschreibungen",
"advanced_styling_section_inputs": "Eingabefelder",
@@ -2966,12 +2956,14 @@
"improve_trial_conversion_question_1_subheader": "Hilf uns, Dich besser zu verstehen:",
"improve_trial_conversion_question_2_button_label": "Weiter",
"improve_trial_conversion_question_2_headline": "Das tut mir leid zu hören. Was war das größte Problem bei der Nutzung von $[projectName]?",
"improve_trial_conversion_question_3_button_label": "Weiter",
"improve_trial_conversion_question_3_headline": "Was haben Sie von $[projectName] erwartet?",
"improve_trial_conversion_question_4_button_label": "Erhalte 20% Rabatt",
"improve_trial_conversion_question_4_headline": "Das tut mir leid zu hören! Erhalte 20% Rabatt im ersten Jahr.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Wir freuen uns, dir einen 20% Rabatt auf einen Jahresplan anzubieten.</span></p>",
"improve_trial_conversion_question_5_button_label": "Weiter",
"improve_trial_conversion_question_5_headline": "Was möchtest Du erreichen?",
"improve_trial_conversion_question_5_subheader": "Bitte wähle eine der folgenden Optionen aus:",
"improve_trial_conversion_question_5_subheader": "Bitte beschreibe unten:",
"improve_trial_conversion_question_6_headline": "Wie löst Du dein Problem heutzutage?",
"improve_trial_conversion_question_6_subheader": "Bitte nenne alternative Lösungen:",
"integration_setup_survey_description": "Bewerte, wie einfach Nutzer Integrationen zu deinem Produkt hinzufügen können.",
+4 -12
View File
@@ -342,7 +342,6 @@
"other": "Other",
"other_filters": "Other Filters",
"other_placeholder": "Other Placeholder",
"others": "Others",
"overlay_color": "Overlay color",
"overview": "Overview",
"password": "Password",
@@ -360,7 +359,6 @@
"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",
@@ -495,7 +493,6 @@
"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.",
@@ -1309,12 +1306,7 @@
"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",
@@ -1367,7 +1359,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": "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_progress_rating_and_nps_description": "Auto-advance in single-question blocks. Required questions hide Next, except when \"Other\" is selected.",
"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",
@@ -2066,7 +2058,6 @@
"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)",
@@ -2154,7 +2145,6 @@
},
"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",
@@ -2966,12 +2956,14 @@
"improve_trial_conversion_question_1_subheader": "Help us understand you better:",
"improve_trial_conversion_question_2_button_label": "Next",
"improve_trial_conversion_question_2_headline": "Sorry to hear. What was the biggest problem using $[projectName]?",
"improve_trial_conversion_question_3_button_label": "Next",
"improve_trial_conversion_question_3_headline": "What did you expect $[projectName] to do?",
"improve_trial_conversion_question_4_button_label": "Get 20% off",
"improve_trial_conversion_question_4_headline": "Sorry to hear! Get 20% off the first year.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We are happy to offer you a 20% discount on a yearly plan.</span></p>",
"improve_trial_conversion_question_5_button_label": "Next",
"improve_trial_conversion_question_5_headline": "What would you like to achieve?",
"improve_trial_conversion_question_5_subheader": "Please select one of the following options:",
"improve_trial_conversion_question_5_subheader": "Please describe below:",
"improve_trial_conversion_question_6_headline": "How are you solving your problem now?",
"improve_trial_conversion_question_6_subheader": "Please name alternative solutions:",
"integration_setup_survey_description": "Evaluate how easily users can add integrations to your product. Find blind spots.",
+9 -17
View File
@@ -342,7 +342,6 @@
"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",
@@ -360,7 +359,6 @@
"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",
@@ -495,7 +493,6 @@
"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.",
@@ -1309,12 +1306,7 @@
"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",
@@ -1367,7 +1359,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": "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_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_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",
@@ -2066,7 +2058,6 @@
"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)",
@@ -2154,7 +2145,6 @@
},
"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",
@@ -2311,11 +2301,11 @@
"advanced_styling_field_track_bg_description": "Colorea la parte no rellenada de la barra.",
"advanced_styling_field_track_height": "Altura de la pista",
"advanced_styling_field_track_height_description": "Controla el grosor de la barra de progreso.",
"advanced_styling_field_upper_label_color": "Color de la etiqueta",
"advanced_styling_field_upper_label_color_description": "Colorea las etiquetas pequeñas sobre los campos de entrada y las etiquetas de escala.",
"advanced_styling_field_upper_label_size": "Tamaño de fuente de la etiqueta",
"advanced_styling_field_upper_label_size_description": "Escala las etiquetas pequeñas sobre los campos de entrada y las etiquetas de escala.",
"advanced_styling_field_upper_label_weight": "Grosor de fuente de la etiqueta",
"advanced_styling_field_upper_label_color": "Color de etiqueta",
"advanced_styling_field_upper_label_color_description": "Colorea las pequeñas etiquetas sobre los campos de entrada y las etiquetas de escala.",
"advanced_styling_field_upper_label_size": "Tamaño de fuente de etiqueta",
"advanced_styling_field_upper_label_size_description": "Escala las pequeñas etiquetas sobre los campos de entrada y las etiquetas de escala.",
"advanced_styling_field_upper_label_weight": "Grosor de fuente de etiqueta",
"advanced_styling_field_upper_label_weight_description": "Hace que las etiquetas sean más ligeras o más gruesas.",
"advanced_styling_section_buttons": "Botones",
"advanced_styling_section_headlines": "Títulos y descripciones",
@@ -2966,12 +2956,14 @@
"improve_trial_conversion_question_1_subheader": "Ayúdanos a entenderte mejor:",
"improve_trial_conversion_question_2_button_label": "Siguiente",
"improve_trial_conversion_question_2_headline": "Lamentamos oír eso. ¿Cuál fue el mayor problema al usar $[projectName]?",
"improve_trial_conversion_question_3_button_label": "Siguiente",
"improve_trial_conversion_question_3_headline": "¿Qué esperabas que hiciera $[projectName]?",
"improve_trial_conversion_question_4_button_label": "Obtener 20 % de descuento",
"improve_trial_conversion_question_4_headline": "¡Sentimos oírlo! Obtén un 20 % de descuento en el primer año.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Nos complace ofrecerte un 20 % de descuento en un plan anual.</span></p>",
"improve_trial_conversion_question_5_button_label": "Siguiente",
"improve_trial_conversion_question_5_headline": "¿Qué te gustaría conseguir?",
"improve_trial_conversion_question_5_subheader": "Por favor, selecciona una de las siguientes opciones:",
"improve_trial_conversion_question_5_subheader": "Por favor, describe a continuación:",
"improve_trial_conversion_question_6_headline": "¿Cómo estás solucionando tu problema ahora?",
"improve_trial_conversion_question_6_subheader": "Por favor, nombra soluciones alternativas:",
"integration_setup_survey_description": "Evalúa con qué facilidad los usuarios pueden añadir integraciones a tu producto. Encuentra puntos ciegos.",
+7 -15
View File
@@ -342,7 +342,6 @@
"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",
@@ -360,7 +359,6 @@
"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",
@@ -495,7 +493,6 @@
"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.",
@@ -1309,12 +1306,7 @@
"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",
@@ -1367,7 +1359,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": "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_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_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",
@@ -2066,7 +2058,6 @@
"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)",
@@ -2154,7 +2145,6 @@
},
"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",
@@ -2312,11 +2302,11 @@
"advanced_styling_field_track_height": "Hauteur de la piste",
"advanced_styling_field_track_height_description": "Contrôle l'épaisseur de la barre de progression.",
"advanced_styling_field_upper_label_color": "Couleur de l'étiquette",
"advanced_styling_field_upper_label_color_description": "Colore les petits libellés au-dessus des champs de saisie et les libellés d'échelle.",
"advanced_styling_field_upper_label_color_description": "Colore les petites étiquettes au-dessus des champs de saisie et des échelles.",
"advanced_styling_field_upper_label_size": "Taille de police de l'étiquette",
"advanced_styling_field_upper_label_size_description": "Ajuste la taille des petits libellés au-dessus des champs de saisie et des libellés d'échelle.",
"advanced_styling_field_upper_label_size_description": "Ajuste la taille des petites étiquettes au-dessus des champs de saisie et des échelles.",
"advanced_styling_field_upper_label_weight": "Graisse de police de l'étiquette",
"advanced_styling_field_upper_label_weight_description": "Rend les libellés plus légers ou plus gras.",
"advanced_styling_field_upper_label_weight_description": "Rend les étiquettes plus légères ou plus grasses.",
"advanced_styling_section_buttons": "Boutons",
"advanced_styling_section_headlines": "Titres et descriptions",
"advanced_styling_section_inputs": "Champs de saisie",
@@ -2966,12 +2956,14 @@
"improve_trial_conversion_question_1_subheader": "Aidez-nous à mieux vous comprendre :",
"improve_trial_conversion_question_2_button_label": "Suivant",
"improve_trial_conversion_question_2_headline": "Désolé d'apprendre cela. Quel était le plus gros problème rencontré avec $[projectName] ?",
"improve_trial_conversion_question_3_button_label": "Suivant",
"improve_trial_conversion_question_3_headline": "Qu'attendiez-vous de $[projectName] ?",
"improve_trial_conversion_question_4_button_label": "Obtenez 20 % de réduction",
"improve_trial_conversion_question_4_headline": "Désolé d'apprendre cela ! Bénéficiez de 20 % de réduction sur la première année.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Nous sommes heureux de vous offrir une remise de 20 % sur un plan annuel.</span></p>",
"improve_trial_conversion_question_5_button_label": "Suivant",
"improve_trial_conversion_question_5_headline": "Que souhaitez-vous accomplir ?",
"improve_trial_conversion_question_5_subheader": "Veuillez sélectionner l'une des options suivantes :",
"improve_trial_conversion_question_5_subheader": "Merci de décrire ci-dessous :",
"improve_trial_conversion_question_6_headline": "Comment résolvez-vous votre problème maintenant ?",
"improve_trial_conversion_question_6_subheader": "Veuillez nommer des solutions alternatives :",
"integration_setup_survey_description": "Évaluez la facilité avec laquelle les utilisateurs peuvent ajouter des intégrations à votre produit. Identifiez les points aveugles.",
+63 -71
View File
@@ -63,8 +63,8 @@
"login_with_email": "Bejelentkezés e-mail-címmel",
"lost_access": "Elvesztette a hozzáférést?",
"new_to_formbricks": "Új a Formbicksen?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"oauth_account_not_linked_description": "Ez az SSO-szolgáltató nincs összekapcsolva egy meglévő Formbricks-fiókkal. Jelentkezzen be az eredetileg használt módszerrel. Ha ez e-mail és jelszó páros volt, akkor először végezze el az e-mail-ellenőrzést, ha a rendszer erre kéri.",
"oauth_account_not_linked_title": "Ezt az SSO-bejelentkezést nem sikerült összekapcsolni",
"use_a_backup_code": "Visszaszerzési kód használata"
},
"saml_connection_error": "Valami probléma történt. A további részletekért nézze meg az alkalmazás konzolját.",
@@ -150,8 +150,8 @@
"bottom_right": "Jobbra lent",
"cancel": "Mégse",
"centered_modal": "Középre helyezett kizárólagos",
"change_organization": "Szervezet módosítása",
"change_workspace": "Munkaterület módosítása",
"change_organization": "Szervezet megváltoztatása",
"change_workspace": "Munkaterület megváltoztatása",
"choice_n": "{{n}}. választás",
"choices": "Választási lehetőségek",
"choose_environment": "Környezet kiválasztása",
@@ -173,7 +173,7 @@
"connect": "Kapcsolódás",
"connect_formbricks": "Kapcsolódás a Formbrickshez",
"connected": "Kapcsolódva",
"contact": "Kapcsolat",
"contact": "Partner",
"contacts": "Partnerek",
"continue": "Folytatás",
"copied": "Másolva",
@@ -238,7 +238,7 @@
"failed_to_copy_to_clipboard": "Nem sikerült másolni a vágólapra",
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
"field_placeholder": "{{field}} helyőrző",
"field_placeholder": "{{field}} helykitöltője",
"filter": "Szűrő",
"finish": "Befejezés",
"first_name": "Keresztnév",
@@ -250,7 +250,7 @@
"generate": "Előállítás",
"go_back": "Vissza",
"go_to_dashboard": "Ugrás a vezérlőpultra",
"headline": "Címsor",
"headline": "Főcím",
"hidden": "Rejtett",
"hidden_field": "Rejtett mező",
"hidden_fields": "Rejtett mezők",
@@ -272,7 +272,7 @@
"invite": "Meghívás",
"invite_them": "Meghívó nekik",
"javascript_required": "JavaScript szükséges",
"javascript_required_description": "A Formbricks használatához JavaScript szükséges. Kérjük, engedélyezze a JavaScriptet a böngésző beállításaiban a folytatáshoz.",
"javascript_required_description": "A Formbricks megfelelő működéséhez JavaScript szükséges. Engedélyezze a JavaScriptet a böngésző beállításaiban a folytatáshoz.",
"key": "Kulcs",
"label": "Címke",
"language": "Nyelv",
@@ -326,9 +326,9 @@
"notifications": "Értesítések",
"number": "Szám",
"off": "Ki",
"offline_all_responses_synced": "Az Ön válasza sikeresen mentésre került.",
"offline_syncing_responses": "Az Ön válaszainak szinkronizálása folyamatban…",
"offline_you_are_offline": "Ön offline állapotban van. Az Ön válasza a böngészőjében tárolásra került, és mentésre kerül, amint ismét online lesz.",
"offline_all_responses_synced": "A válasz sikeresen el lett mentve.",
"offline_syncing_responses": "Válaszok szinkronizálása…",
"offline_you_are_offline": "Ön nem érhető el. A válasza a böngészőjében van tárolva, és akkor lesz elmentve, ha újra elérhető lesz.",
"on": "Be",
"only_one_file_allowed": "Csak egy fájl engedélyezett",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
@@ -341,8 +341,7 @@
"organization_settings": "Szervezet beállításai",
"other": "Egyéb",
"other_filters": "Egyéb szűrők",
"other_placeholder": "Egyéb helyőrző",
"others": "Mások",
"other_placeholder": "Egyéb helykitöltő",
"overlay_color": "Rávetítés színe",
"overview": "Áttekintés",
"password": "Jelszó",
@@ -360,7 +359,6 @@
"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",
@@ -448,7 +446,7 @@
"team_name": "Csapat neve",
"team_role": "Csapatszerep",
"teams": "Csapatok",
"terms_of_service": "Felhasználási feltételek",
"terms_of_service": "Használati feltételek",
"text": "Szöveg",
"time": "Idő",
"time_to_finish": "Idő a befejezésig",
@@ -495,7 +493,6 @@
"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.",
@@ -558,7 +555,7 @@
"verification_email_heading": "Már majdnem kész vagyunk!",
"verification_email_hey": "Helló 👋",
"verification_email_if_expired_request_new_token": "Ha lejárt, kérjen új tokent itt:",
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.",
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 óráig érvényes.",
"verification_email_request_new_verification": "Új ellenőrzés kérése",
"verification_email_subject": "Ellenőrizze az e-mail-címét a Formbricks használatához",
"verification_email_survey_name": "Kérdőív neve",
@@ -867,16 +864,16 @@
"created_by_third_party": "Harmadik fél által létrehozva",
"discord_webhook_not_supported": "A Discord webhorgok jelenleg nem támogatottak.",
"empty_webhook_message": "A webhorgai itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
"endpoint_bad_gateway_error": "Hibás átjáró (502): Proxy-/átjáróhiba, a szolgáltatás nem érhető el",
"endpoint_gateway_timeout_error": "Átjáró időtúllépés (504): Átjáró időtúllépés, a szolgáltatás nem érhető el",
"endpoint_internal_server_error": "Belső szerverhiba (500): A szolgáltatás váratlan hibába ütközött",
"endpoint_method_not_allowed_error": "A metódus nem engedélyezett (405): A végpont létezik, de nem fogad POST kéréseket",
"endpoint_not_found_error": "Nem található (404): A végpont nem létezik",
"endpoint_bad_gateway_error": "Hibás átjáró (502): proxy- vagy átjáróhiba, a szolgáltatás nem érhető el",
"endpoint_gateway_timeout_error": "Átjáró időtúllépés (504): átjáró időtúllépés, a szolgáltatás nem érhető el",
"endpoint_internal_server_error": "Belső kiszolgálóhiba (500): a szolgáltatás váratlan hibába ütközött",
"endpoint_method_not_allowed_error": "A módszer nem engedélyezett (405): a végpont létezik, de nem fogad POST-kéréseket",
"endpoint_not_found_error": "Nem található (404): a végpont nem létezik",
"endpoint_pinged": "Hurrá! Képesek vagyunk pingelni a webhorgot!",
"endpoint_pinged_error": "Nem lehet pingelni a webhorgot!",
"endpoint_service_unavailable_error": "A szolgáltatás nem érhető el (503): A szolgáltatás átmenetileg nem elérhető",
"endpoint_service_unavailable_error": "A szolgáltatás nem érhető el (503): a szolgáltatás átmenetileg nem érhető el",
"learn_to_verify": "Tudja meg, hogy kell ellenőrizni a webhorog aláírásait",
"no_triggers": "Nincsenek Triggerek",
"no_triggers": "Nincsenek aktiválók",
"please_check_console": "További részletekért nézze meg a konzolt",
"please_enter_a_url": "Adjon meg egy URL-t",
"response_created": "Válasz létrehozva",
@@ -892,7 +889,7 @@
"webhook_created": "Webhorog létrehozva",
"webhook_delete_confirmation": "Biztosan törölni szeretné ezt a webhorgot? Ez le fogja állítani a jövőbeli értesítések küldését.",
"webhook_deleted_successfully": "A webhorog sikeresen törölve",
"webhook_name_placeholder": "Választható: címkézze meg a webhorgot az egyszerű azonosításért",
"webhook_name_placeholder": "Elhagyható: címkézze meg a webhorgot az egyszerű azonosításért",
"webhook_test_failed_due_to": "A webhorog tesztelése sikertelen a következő miatt:",
"webhook_updated_successfully": "A webhorog sikeresen frissítve",
"webhook_url_placeholder": "Illessze be azt az URL-t, amelyen az eseményt aktiválni szeretné"
@@ -1018,9 +1015,9 @@
"plan_change_applied": "A csomag sikeresen frissítve.",
"plan_change_scheduled": "A csomagváltoztatás sikeresen ütemezve.",
"plan_custom": "Egyéni",
"plan_feature_everything_in_hobby": "Minden a Hobbi csomagban",
"plan_feature_everything_in_hobby": "Minden a Hobby csomagban",
"plan_feature_everything_in_pro": "Minden a Pro csomagban",
"plan_hobby": "Hobbi",
"plan_hobby": "Hobby",
"plan_hobby_description": "Magánszemélyeknek és kis csapatoknak, akik most teszik meg a kezdeti lépéseket a Formbricks Cloud szolgáltatással.",
"plan_hobby_feature_responses": "250 válasz/hónap",
"plan_hobby_feature_workspaces": "1 munkaterület",
@@ -1028,11 +1025,11 @@
"plan_pro_description": "Növekvő csapatoknak, akiknek magasabb korlátokra, automatizálásra és dinamikus túllépési lehetőségekre van szükségük.",
"plan_pro_feature_responses": "2000 válasz/hónap (dinamikus túllépés)",
"plan_pro_feature_workspaces": "3 munkaterület",
"plan_scale": "Méretezés",
"plan_scale": "Scale",
"plan_scale_description": "Nagyobb csapatoknak, amelyeknek több kapacitásra, erősebb irányításra és nagyobb válaszmennyiségre van szükségük.",
"plan_scale_feature_responses": "5000 válasz/hónap (dinamikus túllépés)",
"plan_scale_feature_workspaces": "5 munkaterület",
"plan_selection_description": "Hobbi, Pro és Méretezés csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.",
"plan_selection_description": "Hobby, Pro és Scale csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.",
"plan_selection_title": "Csomag kiválasztása",
"plan_unknown": "Ismeretlen",
"remove_branding": "Márkajel eltávolítása",
@@ -1040,7 +1037,7 @@
"select_plan_header_subtitle": "Nincs szükség hitelkártyára, nincs kötöttség.",
"select_plan_header_title": "Zökkenőmentesen integrált kérdőívek, 100%-ban az Ön márkájához igazítva.",
"status_trialing": "Próbaidőszak",
"stay_on_hobby_plan": "A Hobbi csomagnál szeretnék maradni",
"stay_on_hobby_plan": "A Hobby csomagnál szeretnék maradni",
"stripe_setup_incomplete": "A számlázási beállítás befejezetlen",
"stripe_setup_incomplete_description": "A számlázási beállítás nem fejeződött be sikeresen. Próbálja meg újra aktiválni az előfizetését.",
"subscription": "Előfizetés",
@@ -1145,17 +1142,17 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "A Formbricks teljes erejének feloldása. 30 napig ingyen."
},
"general": {
"ai_data_analysis_disabled_for_organization": "Az MI-alapú adatelemzés és adatgazdagítás ki van kapcsolva ennél a szervezetnél.",
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
"ai_data_analysis_enabled_description": "AI segítségével többet hozhat ki az adataiból, irányítópultokat, diagramokat, jelentéseket és egyebeket állíthat be. Hozzáfér az élményekhez kapcsolódó adatokhoz.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "AI-alapú funkciók kezelése ehhez a szervezethez.",
"ai_data_analysis_disabled_for_organization": "Az MI-adatelemzés le van tiltva ennél a szervezetnél.",
"ai_data_analysis_enabled": "Adatgazdagítás és -elemzés (MI)",
"ai_data_analysis_enabled_description": "Mesterséges intelligencia ahhoz, hogy többet hozzon ki az adataiból. Vezérlőpultok, diagramok, jelentések és még sok más beállítása. Az élményadatokra is kiterjed.",
"ai_enabled": "Formbricks MI",
"ai_enabled_description": "MI-alapú funkciók kezelése ennél a szervezetnél.",
"ai_features_not_enabled_for_organization": "Az MI-funkciók nincsenek engedélyezve ennél a szervezetnél.",
"ai_instance_not_configured": "Az MI példányszinten, környezeti változókkal van konfigurálva. Kérd meg a rendszergazdát, hogy állítsa be az AI_PROVIDER értékét, a szolgáltató hitelesítő adatait és a megfelelő modelllistát, mielőtt engedélyezné az MI-funkciókat.",
"ai_settings_updated_successfully": "AI beállítások sikeresen frissítve",
"ai_smart_tools_disabled_for_organization": "Az MI intelligens funkciói ki vannak kapcsolva ennél a szervezetnél.",
"ai_smart_tools_enabled": "Intelligens funkciók (AI)",
"ai_smart_tools_enabled_description": "AI segítségével kevesebb idő alatt többet érhet el. Soha nem fér hozzá a Formbricks által gyűjtött adatokhoz. Csak például felmérések más nyelvekre történő fordításához használatos.",
"ai_instance_not_configured": "Az MI példányszinten van beállítva környezeti változókon keresztül. Kérje meg az adminisztrátort, hogy állítsa be az AI_PROVIDER, AI_MODEL és a hozzájuk tartozó szolgáltató hitelesítési adatait, mielőtt engedélyezné az MI-funkciókat.",
"ai_settings_updated_successfully": "Az MI-beállítások sikeresen frissítve",
"ai_smart_tools_disabled_for_organization": "Az MI intelligens eszközei le vannak tiltva ennél a szervezetnél.",
"ai_smart_tools_enabled": "Intelligens funkcionalitás (MI)",
"ai_smart_tools_enabled_description": "Mesterséges intelligencia ahhoz, hogy segítsen Önnek többet elérni kevesebb idő alatt. Soha sem érinti a Formbricks segítségével gyűjtött adatokat. Csak például a kérdőívek más nyelvekre történő fordításához kerül felhasználásra.",
"bulk_invite_warning_description": "Az ingyenes csomagban az összes szervezeti tag mindig a „Tulajdonos” szerephez van hozzárendelve.",
"cannot_delete_only_organization": "Ez az egyetlen szervezete, nem lehet törölni. Először hozzon létre egy új szervezetet.",
"cannot_leave_only_organization": "Nem hagyhatja el ezt a szervezetet, mivel ez az egyetlen szervezete. Először hozzon létre egy új szervezetet.",
@@ -1309,12 +1306,7 @@
"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",
@@ -1366,8 +1358,8 @@
"assign": "= hozzárendelése",
"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, 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_progress_rating_and_nps": "Értékelés és valós ügyfél-támogatottsági érték kérdések automatikus feldolgozása",
"auto_progress_rating_and_nps_description": "Automatikus továbblépés az egykérdéses blokkokban. A kötelező kérdések elrejtik a Tovább gombot, kivéve ha az „Egyéb” van kiválasztva.",
"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",
@@ -1413,7 +1405,7 @@
"caution_text": "A változtatások következetlenségekhez vezetnek",
"change_anyway": "Változtatás mindenképp",
"change_background": "Háttér megváltoztatása",
"change_default": "Alapértelmezett módosítása",
"change_default": "Alapértelmezett megváltoztatása",
"change_question_type": "Kérdés típusának megváltoztatása",
"change_survey_type": "A kérdőív típusának megváltoztatása befolyásolja a meglévő hozzáférést",
"change_the_background_to_a_color_image_or_animation": "A háttér megváltoztatása színre, képre vagy animációra.",
@@ -1612,7 +1604,7 @@
"matrix_rows": "Sorok",
"max_file_size": "Legnagyobb fájlméret",
"max_file_size_limit_is": "A legnagyobb fájlméretkorlát",
"missing_first": "Hiányzók először",
"missing_first": "Hiányzik az első",
"move_question_to_block": "Kérdés áthelyezése egy blokkba",
"multiply": "Szorzás *",
"needed_for_self_hosted_cal_com_instance": "Saját üzemeltetésű Cal.com-példányhoz szükséges",
@@ -1620,7 +1612,7 @@
"next_button_label": "A „Következő” gomb címkéje",
"no_hidden_fields_yet_add_first_one_below": "Még nincsenek rejtett mezők. Adja hozzá az elsőt lent.",
"no_images_found_for": "Nem találhatók képek a(z) „{query}” lekérdezéshez",
"no_languages_found_add_first_one_to_get_started": "Nem található felmérési nyelv ebben a munkaterületen. Kérem, adjon hozzá egyet a kezdéshez.",
"no_languages_found_add_first_one_to_get_started": "Nem találhatók kérdőívnyelvek ezen a munkaterületen. Adja hozzá egyet a kezdéshez.",
"no_option_found": "Nem található lehetőség",
"no_recall_items_found": "Nem találhatók visszahívási elemek",
"no_variables_yet_add_first_one_below": "Még nincsenek változók. Adja hozzá az elsőt lent.",
@@ -1631,7 +1623,7 @@
"only_people_who_match_your_targeting_can_be_surveyed": "Csak azok a személyek kérdezhetők meg, akik megfelelnek a célcsoportnak.",
"option_idx": "{choiceIndex}. lehetőség",
"option_used_in_logic_error": "Ez a lehetőség használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
"optional": "Választható",
"optional": "Elhagyható",
"options": "Beállítások*",
"options_used_in_logic_bulk_error": "A következő lehetőségek használatban vannak a logikában: {questionIndexes}. Először távolítsa el azokat a logikából.",
"override_theme_with_individual_styles_for_this_survey": "A téma felülírása egyéni stílusokkal ennél a kérdőívnél.",
@@ -1647,7 +1639,7 @@
"please_enter_a_valid_url": "Adjon meg egy érvényes URL-t (például https://example.com)",
"please_set_a_survey_trigger": "Állítson be kérdőív-aktiválót",
"please_specify": "Adja meg",
"present_your_survey_in_multiple_languages": "Mutassa be felmérését több nyelven",
"present_your_survey_in_multiple_languages": "A kérdőív bemutatása több nyelven",
"prevent_double_submission": "Kettős beküldés megakadályozása",
"prevent_double_submission_description": "E-mail-címenként csak 1 válasz engedélyezése",
"progress_saved": "Folyamat elmentve",
@@ -1716,8 +1708,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "A válaszkorlátnak meg kell haladnia a kapott válaszok számát ({responseCount}).",
"response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.",
"response_options": "Válasz beállításai",
"reverse_order_occasionally": "Sorrend alkalmi megfordítása",
"reverse_order_occasionally_except_last": "Sorrend alkalmi megfordítása az utolsó kivételével",
"reverse_order_occasionally": "Időnként fordított sorrendben",
"reverse_order_occasionally_except_last": "Időnként fordított sorrendben, kivéve az utolsó",
"roundness": "Kerekesség",
"roundness_description": "Annak vezérlése, hogy a sarkok mennyire legyenek lekerekítve.",
"row_used_in_logic_error": "Ez a sor használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
@@ -1739,7 +1731,7 @@
"seven_points": "7 pont",
"show_block_settings": "Blokkbeállítások megjelenítése",
"show_button": "Gomb megjelenítése",
"show_in_order": "Sorrendben megjelenítés",
"show_in_order": "Megjelenítés sorrendben",
"show_language_switch": "Nyelvválasztó megjelenítése",
"show_multiple_times": "Megjelenítés korlátozott számú alkalommal",
"show_only_once": "Megjelenítés csak egyszer",
@@ -1778,7 +1770,7 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Megjelenítés egyetlen alkalommal, még akkor is, ha nem válaszolnak.",
"then": "Azután",
"this_action_will_remove_all_the_translations_from_this_survey": "Ez a művelet eltávolítja az összes fordítást ebből a kérdőívből.",
"this_will_remove_the_language_and_all_its_translations": "Ez eltávolítja ezt a nyelvet és az összes fordítását ebből a felmérésből. Ez a művelet nem vonható vissza.",
"this_will_remove_the_language_and_all_its_translations": "Ez el fogja távolítani ezt a nyelvet és annak összes fordítását ebből a kérdőívből. Ezt a műveletet nem lehet visszavonni.",
"three_points": "3 pont",
"times": "alkalom",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Ahhoz, hogy következetesen megtartsa az elhelyezést az összes kérdőívnél, az alábbiakat teheti:",
@@ -1798,11 +1790,11 @@
"upper_label": "Felső címke",
"url_filters": "URL szűrők",
"url_not_supported": "Az URL nem támogatott",
"validate_id_duplicate": "A(z) {type} azonosító már létezik a kérdések, rejtett mezők vagy változók között.",
"validate_id_empty": "Kérjük, adjon meg egy {type} azonosítót.",
"validate_id_invalid_chars": "A(z) {type} azonosító nem engedélyezett. Kérjük, csak alfanumerikus karaktereket, kötőjeleket vagy aláhúzásjeleket használjon.",
"validate_id_no_spaces": "A(z) {type} azonosító nem tartalmazhat szóközöket. Kérjük, távolítsa el a szóközöket.",
"validate_id_reserved": "A(z) {type} azonosító \"{field}\" nem engedélyezett. Ez egy fenntartott kulcsszó.",
"validate_id_duplicate": "A {type} azonosítója már létezik a kérdésekben, rejtett mezőkben vagy változókban.",
"validate_id_empty": "Adja meg egy {type} azonosítót.",
"validate_id_invalid_chars": "A {type} azonosítója nem engedélyezett. Használjon csak alfanumerikus karaktereket, kötőjeleket vagy aláhúzásjeleket.",
"validate_id_no_spaces": "A {type} azonosítója nem tartalmazhat szóközöket. Távolítsa el a szóközöket.",
"validate_id_reserved": "A {type} „{field}” azonosítója nem engedélyezett. Ez egy foglalt kulcsszó.",
"validation": {
"add_validation_rule": "Ellenőrzési szabály hozzáadása",
"answer_all_rows": "Válaszoljon az összes sorra",
@@ -2066,7 +2058,6 @@
"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)",
@@ -2144,7 +2135,7 @@
"this_quarter": "Ez a negyedév",
"this_year": "Ez az év",
"time_to_complete": "Kitöltéshez szükséges idő",
"ttc_survey_tooltip": "A felmérés kitöltésének átlagos ideje.",
"ttc_survey_tooltip": "A kérdőív megválaszolásának átlagos ideje.",
"ttc_tooltip": "A kérdés megválaszolásának átlagos ideje.",
"unknown_question_type": "Ismeretlen kérdéstípus",
"use_personal_links": "Személyes hivatkozások használata",
@@ -2154,7 +2145,6 @@
},
"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",
@@ -2234,7 +2224,7 @@
"languages": {
"add_language": "Nyelv hozzáadása",
"alias": "Álnév",
"alias_tooltip": "Az álnév egy alternatív név a hivatkozás-kérdőívekben és az SDK-ban lévő nyelv azonosításához (választható)",
"alias_tooltip": "Az álnév egy alternatív név a hivatkozás-kérdőívekben és az SDK-ban lévő nyelv azonosításához (elhagyható)",
"cannot_remove_language_warning": "Nem tudja eltávolítani ezt a nyelvet, mert még mindig használatban van ezekben a kérdőívekben:",
"conflict_between_identifier_and_alias": "Ütközés van egy hozzáadott nyelv azonosítója és az álnevei egyike között. Az álnevek és az azonosítók nem lehetnek azonosak.",
"conflict_between_selected_alias_and_another_language": "Ütközés van a kiválasztott álnév és egy másik, ezzel az azonosítóval rendelkező nyelv között. A következetlenségek elkerülése érdekében ezzel az azonosítóval adja hozzá a nyelvet a munkaterületéhez.",
@@ -2312,9 +2302,9 @@
"advanced_styling_field_track_height": "Követés magassága",
"advanced_styling_field_track_height_description": "A folyamatjelző vastagságát vezérli.",
"advanced_styling_field_upper_label_color": "Címke színe",
"advanced_styling_field_upper_label_color_description": "Kiszínezi a beviteli mezők fölötti kis címkéket és a skálacímkéket.",
"advanced_styling_field_upper_label_color_description": "Kiszínezi a beviteli mezők fölötti kis címkéket és a méretezés címkéit.",
"advanced_styling_field_upper_label_size": "Címke betűmérete",
"advanced_styling_field_upper_label_size_description": "Átméretezi a beviteli mezők fölötti kis címkéket és a skálacímkéket.",
"advanced_styling_field_upper_label_size_description": "Átméretezi a beviteli mezők fölötti kis címkéket és a méretezés címkéit.",
"advanced_styling_field_upper_label_weight": "Címke betűvastagsága",
"advanced_styling_field_upper_label_weight_description": "Vékonyabbá vagy vastagabbá teszi a címkéket.",
"advanced_styling_section_buttons": "Gombok",
@@ -2653,7 +2643,7 @@
"csat_question_1_headline": "Mennyire valószínű, hogy ezt a(z) $[projectName] projektet ajánlaná egy ismerősnek vagy kollégának?",
"csat_question_1_lower_label": "Nem valószínű",
"csat_question_1_upper_label": "Nagyon valószínű",
"csat_question_2_choice_1": "Részben elégedett",
"csat_question_2_choice_1": "Valamelyest elégedett",
"csat_question_2_choice_2": "Nagyon elégedett",
"csat_question_2_choice_3": "Sem elégedett, sem elégedetlen",
"csat_question_2_choice_4": "Valamelyest elégedetlen",
@@ -2887,10 +2877,10 @@
"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_1": "A felhasználói bázisom alapos megértése",
"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_choice_4": "Világuralom szerezése, 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",
@@ -2966,12 +2956,14 @@
"improve_trial_conversion_question_1_subheader": "Segítsen nekünk jobban megérteni Önt:",
"improve_trial_conversion_question_2_button_label": "Következő",
"improve_trial_conversion_question_2_headline": "Sajnálattal halljuk. Mi volt a legnagyobb probléma a(z) $[projectName] projekt használatával?",
"improve_trial_conversion_question_3_button_label": "Következő",
"improve_trial_conversion_question_3_headline": "Mit vár el a(z) $[projectName] projekttől?",
"improve_trial_conversion_question_4_button_label": "20% kedvezmény",
"improve_trial_conversion_question_4_headline": "Sajnálattal halljuk! 20% kedvezményt kap az első évre.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Boldogan felajánlunk 20% kedvezményt az éves csomagra.</span></p>",
"improve_trial_conversion_question_5_button_label": "Következő",
"improve_trial_conversion_question_5_headline": "Mit szeretne elérni?",
"improve_trial_conversion_question_5_subheader": "Válassza ki a következő lehetőségek egyikét:",
"improve_trial_conversion_question_5_subheader": "Írja le az alábbiakban:",
"improve_trial_conversion_question_6_headline": "Hogyan oldja meg a problémáját most?",
"improve_trial_conversion_question_6_subheader": "Nevezzen meg alternatív megoldásokat:",
"integration_setup_survey_description": "Annak kiértékelése, hogy a felhasználók mennyire könnyen tudnak integrációkat hozzáadni a termékéhez. A vakfoltok megtalálása.",
+8 -16
View File
@@ -342,7 +342,6 @@
"other": "その他",
"other_filters": "その他のフィルター",
"other_placeholder": "その他のプレースホルダー",
"others": "その他",
"overlay_color": "オーバーレイの色",
"overview": "概要",
"password": "パスワード",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "プランをアップグレードしてください",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "プレビュー",
"preview_survey": "フォームをプレビュー",
"privacy": "プライバシーポリシー",
"product_manager": "プロダクトマネージャー",
"production": "本番",
@@ -495,7 +493,6 @@
"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}件に達しました。",
@@ -1309,12 +1306,7 @@
"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": "翻訳を有効化",
@@ -1367,7 +1359,7 @@
"audience": "オーディエンス",
"auto_close_on_inactivity": "非アクティブ時に自動閉鎖",
"auto_progress_rating_and_nps": "評価とNPSの質問を自動進行",
"auto_progress_rating_and_nps_description": "評価またはNPSの質問で回答者が選択肢を選んだ際に自動的に次へ進みます。これは単一質問ブロックにのみ適用されます。必須質問では「次へ」ボタンが非表示になり、任意の質問ではスキップ用に引き続き表示されます。",
"auto_progress_rating_and_nps_description": "単一質問ブロックで自動的に次へ進みます。必須質問では「次へ」ボタンが非表示になりますが、「その他」が選択された場合は表示されます。",
"auto_save_disabled": "自動保存が無効",
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
"auto_save_on": "自動保存オン",
@@ -2066,7 +2058,6 @@
"downloading_qr_code": "QRコードをダウンロード中",
"drop_offs": "離脱",
"drop_offs_tooltip": "フォームが開始されたが完了しなかった回数。",
"failed_to_copy_link": "リンクのコピーに失敗しました",
"filter_added_successfully": "フィルターを正常に追加しました",
"filter_updated_successfully": "フィルターを正常に更新しました",
"filtered_responses_csv": "フィルター済み回答 (CSV)",
@@ -2154,7 +2145,6 @@
},
"survey_deleted_successfully": "フォームを正常に削除しました!",
"survey_duplicated_successfully": "フォームを正常に複製しました。",
"survey_duplication_error": "フォームの複製に失敗しました。",
"templates": {
"all_channels": "すべてのチャネル",
"all_industries": "すべての業界",
@@ -2312,11 +2302,11 @@
"advanced_styling_field_track_height": "トラックの高さ",
"advanced_styling_field_track_height_description": "プログレスバーの太さを調整します。",
"advanced_styling_field_upper_label_color": "ラベルの色",
"advanced_styling_field_upper_label_color_description": "入力フィールド上部の小さなラベルとスケールラベルの色を設定します。",
"advanced_styling_field_upper_label_color_description": "入力欄の上にある小さなラベルとスケールラベルの色を設定します。",
"advanced_styling_field_upper_label_size": "ラベルのフォントサイズ",
"advanced_styling_field_upper_label_size_description": "入力フィールド上部の小さなラベルとスケールラベルのサイズを調整します。",
"advanced_styling_field_upper_label_weight": "ラベルのフォント太さ",
"advanced_styling_field_upper_label_weight_description": "ラベルを細くまたは太くします。",
"advanced_styling_field_upper_label_size_description": "入力欄の上にある小さなラベルとスケールラベルのサイズを調整します。",
"advanced_styling_field_upper_label_weight": "ラベルのフォント太さ",
"advanced_styling_field_upper_label_weight_description": "ラベルの太さを細くしたり太くしたりします。",
"advanced_styling_section_buttons": "ボタン",
"advanced_styling_section_headlines": "見出しと説明",
"advanced_styling_section_inputs": "入力フィールド",
@@ -2966,12 +2956,14 @@
"improve_trial_conversion_question_1_subheader": "私たちをよりよく理解するためにお手伝いください:",
"improve_trial_conversion_question_2_button_label": "次へ",
"improve_trial_conversion_question_2_headline": "残念です。$[projectName]を使う上で最も大きな問題は何でしたか?",
"improve_trial_conversion_question_3_button_label": "次へ",
"improve_trial_conversion_question_3_headline": "$[projectName]に何を期待していましたか?",
"improve_trial_conversion_question_4_button_label": "20%オフを取得",
"improve_trial_conversion_question_4_headline": "残念です!初年度20%オフをゲット。",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>年間プランで20%の割引を提供させていただきます。</span></p>",
"improve_trial_conversion_question_5_button_label": "次へ",
"improve_trial_conversion_question_5_headline": "何を達成したいですか?",
"improve_trial_conversion_question_5_subheader": "以下のオプションから一つ選択してください",
"improve_trial_conversion_question_5_subheader": "以下に詳しくご記入ください:",
"improve_trial_conversion_question_6_headline": "今、問題をどのように解決していますか?",
"improve_trial_conversion_question_6_subheader": "代替ソリューションを挙げてください:",
"integration_setup_survey_description": "ユーザーが製品に統合を追加するのがどれだけ簡単かを評価する。盲点を見つける。",
+6 -14
View File
@@ -342,7 +342,6 @@
"other": "Ander",
"other_filters": "Overige filters",
"other_placeholder": "Andere tijdelijke aanduiding",
"others": "Anderen",
"overlay_color": "Overlaykleur",
"overview": "Overzicht",
"password": "Wachtwoord",
@@ -360,7 +359,6 @@
"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",
@@ -495,7 +493,6 @@
"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.",
@@ -1309,12 +1306,7 @@
"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",
@@ -1367,7 +1359,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": "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_progress_rating_and_nps_description": "Automatisch doorgaan bij blokken met één vraag. Verplichte vragen verbergen Volgende, behalve wanneer \"Anders\" is geselecteerd.",
"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",
@@ -2066,7 +2058,6 @@
"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)",
@@ -2154,7 +2145,6 @@
},
"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",
@@ -2314,9 +2304,9 @@
"advanced_styling_field_upper_label_color": "Labelkleur",
"advanced_styling_field_upper_label_color_description": "Kleurt de kleine labels boven invoervelden en schaallabels.",
"advanced_styling_field_upper_label_size": "Lettergrootte label",
"advanced_styling_field_upper_label_size_description": "Schaalt de kleine labels boven invoervelden en schaallabels.",
"advanced_styling_field_upper_label_size_description": "Past de grootte aan van de kleine labels boven invoervelden en schaallabels.",
"advanced_styling_field_upper_label_weight": "Letterdikte label",
"advanced_styling_field_upper_label_weight_description": "Maakt de labels lichter of vetter.",
"advanced_styling_field_upper_label_weight_description": "Maakt de labels lichter of dikgedrukt.",
"advanced_styling_section_buttons": "Knoppen",
"advanced_styling_section_headlines": "Koppen & beschrijvingen",
"advanced_styling_section_inputs": "Invoervelden",
@@ -2966,12 +2956,14 @@
"improve_trial_conversion_question_1_subheader": "Help ons u beter te begrijpen:",
"improve_trial_conversion_question_2_button_label": "Volgende",
"improve_trial_conversion_question_2_headline": "Sorry om te horen. Wat was het grootste probleem bij het gebruik van $[projectName]?",
"improve_trial_conversion_question_3_button_label": "Volgende",
"improve_trial_conversion_question_3_headline": "Wat had je verwacht dat $[projectName] zou doen?",
"improve_trial_conversion_question_4_button_label": "Krijg 20% korting",
"improve_trial_conversion_question_4_headline": "Sorry om te horen! Krijg het eerste jaar 20% korting.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We bieden u graag 20% korting op een jaarabonnement.</span></p>",
"improve_trial_conversion_question_5_button_label": "Volgende",
"improve_trial_conversion_question_5_headline": "Wat zou je graag willen bereiken?",
"improve_trial_conversion_question_5_subheader": "Selecteer een van de volgende opties:",
"improve_trial_conversion_question_5_subheader": "Beschrijf hieronder:",
"improve_trial_conversion_question_6_headline": "Hoe los jij je probleem nu op?",
"improve_trial_conversion_question_6_subheader": "Noem alternatieve oplossingen:",
"integration_setup_survey_description": "Evalueer hoe gemakkelijk gebruikers integraties aan uw product kunnen toevoegen. Zoek blinde vlekken.",
+10 -18
View File
@@ -342,7 +342,6 @@
"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",
@@ -360,7 +359,6 @@
"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",
@@ -495,7 +493,6 @@
"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.",
@@ -1309,12 +1306,7 @@
"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",
@@ -1367,7 +1359,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 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_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_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",
@@ -2066,7 +2058,6 @@
"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)",
@@ -2154,7 +2145,6 @@
},
"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",
@@ -2311,12 +2301,12 @@
"advanced_styling_field_track_bg_description": "Colore a porção não preenchida da barra.",
"advanced_styling_field_track_height": "Altura da trilha",
"advanced_styling_field_track_height_description": "Controla a espessura da barra de progresso.",
"advanced_styling_field_upper_label_color": "Cor do rótulo",
"advanced_styling_field_upper_label_color_description": "Colore os pequenos rótulos acima dos campos de entrada e os rótulos de escala.",
"advanced_styling_field_upper_label_size": "Tamanho da fonte do rótulo",
"advanced_styling_field_upper_label_size_description": "Ajusta o tamanho dos pequenos rótulos acima dos campos de entrada e dos rótulos de escala.",
"advanced_styling_field_upper_label_weight": "Peso da fonte do rótulo",
"advanced_styling_field_upper_label_weight_description": "Torna os rótulos mais leves ou mais negritos.",
"advanced_styling_field_upper_label_color": "Cor do Rótulo",
"advanced_styling_field_upper_label_color_description": "Colore os pequenos rótulos acima dos campos de entrada e rótulos de escala.",
"advanced_styling_field_upper_label_size": "Tamanho da Fonte do Rótulo",
"advanced_styling_field_upper_label_size_description": "Ajusta o tamanho dos pequenos rótulos acima dos campos de entrada e rótulos de escala.",
"advanced_styling_field_upper_label_weight": "Peso da Fonte do Rótulo",
"advanced_styling_field_upper_label_weight_description": "Torna os rótulos mais leves ou mais pesados.",
"advanced_styling_section_buttons": "Botões",
"advanced_styling_section_headlines": "Títulos e descrições",
"advanced_styling_section_inputs": "Campos de entrada",
@@ -2966,12 +2956,14 @@
"improve_trial_conversion_question_1_subheader": "Ajuda a gente a te entender melhor:",
"improve_trial_conversion_question_2_button_label": "Próximo",
"improve_trial_conversion_question_2_headline": "Que chato ouvir isso. Qual foi o maior problema ao usar $[projectName]?",
"improve_trial_conversion_question_3_button_label": "Próximo",
"improve_trial_conversion_question_3_headline": "O que você esperava que $[projectName] fizesse?",
"improve_trial_conversion_question_4_button_label": "Ganhe 20% de desconto",
"improve_trial_conversion_question_4_headline": "Que pena ouvir isso! Ganhe 20% de desconto no primeiro ano.",
"improve_trial_conversion_question_4_html": "Estamos felizes em te oferecer um desconto de 20% no plano anual.",
"improve_trial_conversion_question_5_button_label": "Próximo",
"improve_trial_conversion_question_5_headline": "O que você gostaria de alcançar?",
"improve_trial_conversion_question_5_subheader": "Por favor, escolha uma das opções a seguir:",
"improve_trial_conversion_question_5_subheader": "Por favor, descreva abaixo:",
"improve_trial_conversion_question_6_headline": "Como você tá resolvendo seu problema agora?",
"improve_trial_conversion_question_6_subheader": "Por favor, nomeie soluções alternativas:",
"integration_setup_survey_description": "Avalie quão fácil é para os usuários adicionarem integrações ao seu produto. Encontre pontos cegos.",
+9 -17
View File
@@ -342,7 +342,6 @@
"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",
@@ -360,7 +359,6 @@
"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",
@@ -495,7 +493,6 @@
"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.",
@@ -1309,12 +1306,7 @@
"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",
@@ -1367,7 +1359,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 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_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_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",
@@ -2066,7 +2058,6 @@
"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)",
@@ -2154,7 +2145,6 @@
},
"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",
@@ -2311,12 +2301,12 @@
"advanced_styling_field_track_bg_description": "Colore a porção não preenchida da barra.",
"advanced_styling_field_track_height": "Altura da faixa",
"advanced_styling_field_track_height_description": "Controla a espessura da barra de progresso.",
"advanced_styling_field_upper_label_color": "Cor da etiqueta",
"advanced_styling_field_upper_label_color_description": "Colore as pequenas etiquetas acima dos campos de entrada e as etiquetas de escala.",
"advanced_styling_field_upper_label_size": "Tamanho da fonte da etiqueta",
"advanced_styling_field_upper_label_color": "Cor da Etiqueta",
"advanced_styling_field_upper_label_color_description": "Define a cor das pequenas etiquetas acima dos campos de entrada e das etiquetas de escala.",
"advanced_styling_field_upper_label_size": "Tamanho da Fonte da Etiqueta",
"advanced_styling_field_upper_label_size_description": "Ajusta o tamanho das pequenas etiquetas acima dos campos de entrada e das etiquetas de escala.",
"advanced_styling_field_upper_label_weight": "Peso da fonte da etiqueta",
"advanced_styling_field_upper_label_weight_description": "Torna as etiquetas mais leves ou mais negritas.",
"advanced_styling_field_upper_label_weight": "Espessura da Fonte da Etiqueta",
"advanced_styling_field_upper_label_weight_description": "Torna as etiquetas mais finas ou mais grossas.",
"advanced_styling_section_buttons": "Botões",
"advanced_styling_section_headlines": "Títulos e descrições",
"advanced_styling_section_inputs": "Campos de entrada",
@@ -2966,12 +2956,14 @@
"improve_trial_conversion_question_1_subheader": "Ajude-nos a compreendê-lo melhor:",
"improve_trial_conversion_question_2_button_label": "Seguinte",
"improve_trial_conversion_question_2_headline": "Lamentamos saber. Qual foi o maior problema ao usar $[projectName]?",
"improve_trial_conversion_question_3_button_label": "Seguinte",
"improve_trial_conversion_question_3_headline": "O que esperava que $[projectName] fizesse?",
"improve_trial_conversion_question_4_button_label": "Obtenha 20% de desconto",
"improve_trial_conversion_question_4_headline": "Lamentamos saber! Obtenha 20% de desconto no primeiro ano.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Estamos felizes por lhe oferecer um desconto de 20% num plano anual.</span></p>",
"improve_trial_conversion_question_5_button_label": "Seguinte",
"improve_trial_conversion_question_5_headline": "O que gostaria de alcançar?",
"improve_trial_conversion_question_5_subheader": "Por favor, selecione uma das seguintes opções:",
"improve_trial_conversion_question_5_subheader": "Por favor, descreve abaixo:",
"improve_trial_conversion_question_6_headline": "Como está a resolver o seu problema agora?",
"improve_trial_conversion_question_6_subheader": "Por favor, nomeie soluções alternativas:",
"integration_setup_survey_description": "Avalie a facilidade com que os utilizadores podem adicionar integrações ao seu produto. Encontre pontos cegos.",
+7 -15
View File
@@ -342,7 +342,6 @@
"other": "Altele",
"other_filters": "Alte Filtre",
"other_placeholder": "Alt substituent",
"others": "Altele",
"overlay_color": "Culoare overlay",
"overview": "Prezentare generală",
"password": "Parolă",
@@ -360,7 +359,6 @@
"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",
@@ -495,7 +493,6 @@
"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.",
@@ -1309,12 +1306,7 @@
"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",
@@ -1367,7 +1359,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": "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_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_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ă",
@@ -2066,7 +2058,6 @@
"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)",
@@ -2154,7 +2145,6 @@
},
"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",
@@ -2312,9 +2302,9 @@
"advanced_styling_field_track_height": "Înălțime track",
"advanced_styling_field_track_height_description": "Controlează grosimea barei de progres.",
"advanced_styling_field_upper_label_color": "Culoare etichetă",
"advanced_styling_field_upper_label_color_description": "Colorează etichetele mici de deasupra câmpurilor și etichetele de scală.",
"advanced_styling_field_upper_label_size": "Mărime font etichetă",
"advanced_styling_field_upper_label_size_description": "Redimensionea etichetele mici de deasupra câmpurilor și etichetele de scală.",
"advanced_styling_field_upper_label_color_description": "Colorează etichetele mici de deasupra câmpurilor de introducere și etichetele de scală.",
"advanced_styling_field_upper_label_size": "Dimensiune font etichetă",
"advanced_styling_field_upper_label_size_description": "Ajustează dimensiunea etichetelor mici de deasupra câmpurilor de introducere și a etichetelor de scală.",
"advanced_styling_field_upper_label_weight": "Grosime font etichetă",
"advanced_styling_field_upper_label_weight_description": "Face etichetele mai subțiri sau mai îngroșate.",
"advanced_styling_section_buttons": "Butoane",
@@ -2966,12 +2956,14 @@
"improve_trial_conversion_question_1_subheader": "Ajută-ne să te înțelegem mai bine:",
"improve_trial_conversion_question_2_button_label": "Următorul",
"improve_trial_conversion_question_2_headline": "Ne pare rău să auzim asta. Care a fost cea mai mare problemă folosind $[projectName]?",
"improve_trial_conversion_question_3_button_label": "Următorul",
"improve_trial_conversion_question_3_headline": "Ce ați fi așteptat de la $[projectName]?",
"improve_trial_conversion_question_4_button_label": "Obțineți 20% reducere",
"improve_trial_conversion_question_4_headline": "Ne pare rău să auzim asta! Obțineți 20% reducere în primul an.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Suntem bucuroși să vă oferim o reducere de 20% la un plan anual.</span></p>",
"improve_trial_conversion_question_5_button_label": "Următorul",
"improve_trial_conversion_question_5_headline": "Ce ați dori să obțineți?",
"improve_trial_conversion_question_5_subheader": " rugăm să selectați una dintre următoarele opțiuni:",
"improve_trial_conversion_question_5_subheader": "Te rugăm să descrii mai jos:",
"improve_trial_conversion_question_6_headline": "Cum rezolvați acum problema dumneavoastră?",
"improve_trial_conversion_question_6_subheader": "Vă rugăm să numiți soluțiile alternative:",
"integration_setup_survey_description": "Evaluați cât de ușor pot utilizatorii să adauge integrări la produsul dvs. Identificați punctele oarbe.",
+10 -18
View File
@@ -342,7 +342,6 @@
"other": "Другое",
"other_filters": "Другие фильтры",
"other_placeholder": "Другой заполнитель",
"others": "Другие",
"overlay_color": "Цвет наложения",
"overview": "Обзор",
"password": "Пароль",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план",
"powered_by_formbricks": "Работает на Formbricks",
"preview": "Предпросмотр",
"preview_survey": "Предпросмотр опроса",
"privacy": "Политика конфиденциальности",
"product_manager": "Менеджер продукта",
"production": "Продакшн",
@@ -495,7 +493,6 @@
"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} рабочих пространств.",
@@ -1309,12 +1306,7 @@
"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": "Активировать переводы",
@@ -1367,7 +1359,7 @@
"audience": "Аудитория",
"auto_close_on_inactivity": "Автоматически закрывать при бездействии",
"auto_progress_rating_and_nps": "Автоматический переход для вопросов с оценкой и NPS",
"auto_progress_rating_and_nps_description": "Автоматически переходить к следующему шагу, когда респонденты выбирают ответ в вопросах с оценкой или NPS. Это применяется только к блокам с одним вопросом. В обязательных вопросах кнопка «Далее» скрыта; в необязательных вопросах она остается видимой для пропуска.",
"auto_progress_rating_and_nps_description": "Автоматический переход в блоках с одним вопросом. Обязательные вопросы скрывают кнопку «Далее», за исключением случаев, когда выбран вариант «Другое».",
"auto_save_disabled": "Автосохранение отключено",
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
"auto_save_on": "Автосохранение включено",
@@ -2066,7 +2058,6 @@
"downloading_qr_code": "Скачивание QR-кода",
"drop_offs": "Прерывания",
"drop_offs_tooltip": "Количество раз, когда опрос был начат, но не завершён.",
"failed_to_copy_link": "Не удалось скопировать ссылку",
"filter_added_successfully": "Фильтр успешно добавлен",
"filter_updated_successfully": "Фильтр успешно обновлён",
"filtered_responses_csv": "Отфильтрованные ответы (CSV)",
@@ -2154,7 +2145,6 @@
},
"survey_deleted_successfully": "Опрос успешно удалён!",
"survey_duplicated_successfully": "Опрос успешно продублирован.",
"survey_duplication_error": "Не удалось продублировать опрос.",
"templates": {
"all_channels": "Все каналы",
"all_industries": "Все отрасли",
@@ -2311,12 +2301,12 @@
"advanced_styling_field_track_bg_description": "Задаёт цвет незаполненной части полосы.",
"advanced_styling_field_track_height": "Высота трека",
"advanced_styling_field_track_height_description": "Управляет толщиной индикатора прогресса.",
"advanced_styling_field_upper_label_color": "Цвет метки",
"advanced_styling_field_upper_label_color_description": "Задаёт цвет маленьких меток над полями ввода и меток шкалы.",
"advanced_styling_field_upper_label_size": "Размер шрифта метки",
"advanced_styling_field_upper_label_size_description": "Изменяет размер маленьких меток над полями ввода и меток шкалы.",
"advanced_styling_field_upper_label_weight": "Толщина шрифта метки",
"advanced_styling_field_upper_label_weight_description": "Делает метки тоньше или жирнее.",
"advanced_styling_field_upper_label_color": "Цвет подписи",
"advanced_styling_field_upper_label_color_description": "Задаёт цвет маленьких подписей над полями ввода и подписей шкалы.",
"advanced_styling_field_upper_label_size": "Размер шрифта подписи",
"advanced_styling_field_upper_label_size_description": "Изменяет размер маленьких подписей над полями ввода и подписей шкалы.",
"advanced_styling_field_upper_label_weight": "Насыщенность шрифта подписи",
"advanced_styling_field_upper_label_weight_description": "Делает подписи светлее или жирнее.",
"advanced_styling_section_buttons": "Кнопки",
"advanced_styling_section_headlines": "Заголовки и описания",
"advanced_styling_section_inputs": "Поля ввода",
@@ -2966,12 +2956,14 @@
"improve_trial_conversion_question_1_subheader": "Помогите нам лучше вас понять:",
"improve_trial_conversion_question_2_button_label": "Далее",
"improve_trial_conversion_question_2_headline": "Жаль это слышать. Какая была самая большая проблема при использовании $[projectName]?",
"improve_trial_conversion_question_3_button_label": "Далее",
"improve_trial_conversion_question_3_headline": "Что вы ожидали от $[projectName]?",
"improve_trial_conversion_question_4_button_label": "Получить скидку 20%",
"improve_trial_conversion_question_4_headline": "Жаль это слышать! Получите 20% скидку на первый год.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Мы рады предложить вам скидку 20% на годовой тариф.</span></p>",
"improve_trial_conversion_question_5_button_label": "Далее",
"improve_trial_conversion_question_5_headline": "Чего бы вы хотели достичь?",
"improve_trial_conversion_question_5_subheader": "Пожалуйста, выберите один из следующих вариантов:",
"improve_trial_conversion_question_5_subheader": "Пожалуйста, опишите ниже:",
"improve_trial_conversion_question_6_headline": "Как вы сейчас решаете свою проблему?",
"improve_trial_conversion_question_6_subheader": "Пожалуйста, укажите альтернативные решения:",
"integration_setup_survey_description": "Оцените, насколько легко пользователи могут добавлять интеграции в ваш продукт. Найдите слабые места.",
+10 -18
View File
@@ -342,7 +342,6 @@
"other": "Annat",
"other_filters": "Andra filter",
"other_placeholder": "Annan platshållare",
"others": "Andra",
"overlay_color": "Overlay-färg",
"overview": "Översikt",
"password": "Lösenord",
@@ -360,7 +359,6 @@
"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",
@@ -495,7 +493,6 @@
"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.",
@@ -1309,12 +1306,7 @@
"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",
@@ -1367,7 +1359,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 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_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_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å",
@@ -2066,7 +2058,6 @@
"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)",
@@ -2154,7 +2145,6 @@
},
"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",
@@ -2311,12 +2301,12 @@
"advanced_styling_field_track_bg_description": "Färgar den ofyllda delen av stapeln.",
"advanced_styling_field_track_height": "Spårets höjd",
"advanced_styling_field_track_height_description": "Styr tjockleken på förloppsstapeln.",
"advanced_styling_field_upper_label_color": "Etikettfärg",
"advanced_styling_field_upper_label_color_description": "Färgar de små etiketterna ovanför fälten och skaletiketter.",
"advanced_styling_field_upper_label_size": "Etikettens teckenstorlek",
"advanced_styling_field_upper_label_size_description": "Skalar storleken på de små etiketterna ovanför fälten och skaletiketter.",
"advanced_styling_field_upper_label_weight": "Etikettens teckentjocklek",
"advanced_styling_field_upper_label_weight_description": "Gör etiketterna tunnare eller fetare.",
"advanced_styling_field_upper_label_color": "Etiketfärg",
"advanced_styling_field_upper_label_color_description": "Färglägger de små etiketterna ovanför inmatningsfält och skalans etiketter.",
"advanced_styling_field_upper_label_size": "Etiketttextstorlek",
"advanced_styling_field_upper_label_size_description": "Skalar storleken på de små etiketterna ovanför inmatningsfält och skalans etiketter.",
"advanced_styling_field_upper_label_weight": "Etiketttextvikt",
"advanced_styling_field_upper_label_weight_description": "Gör etiketterna ljusare eller fetare.",
"advanced_styling_section_buttons": "Knappar",
"advanced_styling_section_headlines": "Rubriker & beskrivningar",
"advanced_styling_section_inputs": "Inmatningar",
@@ -2966,12 +2956,14 @@
"improve_trial_conversion_question_1_subheader": "Hjälp oss förstå dig bättre:",
"improve_trial_conversion_question_2_button_label": "Nästa",
"improve_trial_conversion_question_2_headline": "Tråkigt att höra. Vad var det största problemet med att använda $[projectName]?",
"improve_trial_conversion_question_3_button_label": "Nästa",
"improve_trial_conversion_question_3_headline": "Vad förväntade du dig att $[projectName] skulle göra?",
"improve_trial_conversion_question_4_button_label": "Få 20% rabatt",
"improve_trial_conversion_question_4_headline": "Tråkigt att höra! Få 20% rabatt första året.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Vi erbjuder dig gärna 20% rabatt på en årsplan.</span></p>",
"improve_trial_conversion_question_5_button_label": "Nästa",
"improve_trial_conversion_question_5_headline": "Vad vill du uppnå?",
"improve_trial_conversion_question_5_subheader": "Vänligen välj ett av följande alternativ:",
"improve_trial_conversion_question_5_subheader": "Beskriv gärna nedan:",
"improve_trial_conversion_question_6_headline": "Hur löser du ditt problem nu?",
"improve_trial_conversion_question_6_subheader": "Vänligen nämn alternativa lösningar:",
"integration_setup_survey_description": "Utvärdera hur enkelt användare kan lägga till integrationer i din produkt. Hitta blinda fläckar.",
+7 -15
View File
@@ -342,7 +342,6 @@
"other": "其他",
"other_filters": "其他筛选条件",
"other_placeholder": "其他占位符",
"others": "其他",
"overlay_color": "覆盖层颜色",
"overview": "概览",
"password": "密码",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "请升级您的计划",
"powered_by_formbricks": "由 Formbricks 提供支持",
"preview": "预览",
"preview_survey": "预览 Survey",
"privacy": "隐私政策",
"product_manager": "产品经理",
"production": "生产环境",
@@ -495,7 +493,6 @@
"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} 个工作区的上限。",
@@ -1309,12 +1306,7 @@
"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": "激活翻译",
@@ -1367,7 +1359,7 @@
"audience": "受众",
"auto_close_on_inactivity": "自动关闭 在 无活动时",
"auto_progress_rating_and_nps": "自动推进评分和 NPS 问题",
"auto_progress_rating_and_nps_description": "当受访者在评分或 NPS 问题上选择答案时自动前进。这仅适用于单问题区块。必填问题会隐藏\"下一步\"按钮;可选问题仍会显示该按钮以便跳过。",
"auto_progress_rating_and_nps_description": "在单问题块中自动前进。必填问题会隐藏\"下一步\"按钮,除非选择了\"其他\"选项。",
"auto_save_disabled": "自动保存已禁用",
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
"auto_save_on": "自动保存已启用",
@@ -2066,7 +2058,6 @@
"downloading_qr_code": "正在下载二维码",
"drop_offs": "流失",
"drop_offs_tooltip": "调查 被 开始 但 未 完成 的 次数",
"failed_to_copy_link": "复制链接失败",
"filter_added_successfully": "筛选器 添加成功",
"filter_updated_successfully": "筛选器 更新 成功",
"filtered_responses_csv": "过滤 反馈 CSV",
@@ -2154,7 +2145,6 @@
},
"survey_deleted_successfully": "调查 删除 成功",
"survey_duplicated_successfully": "调查成功复制。",
"survey_duplication_error": "无法复制 调查。",
"templates": {
"all_channels": "所有 渠道",
"all_industries": "所有 行业",
@@ -2312,11 +2302,11 @@
"advanced_styling_field_track_height": "轨道高度",
"advanced_styling_field_track_height_description": "控制进度条的粗细。",
"advanced_styling_field_upper_label_color": "标签颜色",
"advanced_styling_field_upper_label_color_description": "设置输入框上方小标签和刻度标签的颜色。",
"advanced_styling_field_upper_label_color_description": "输入框上方小标签和刻度标签色。",
"advanced_styling_field_upper_label_size": "标签字体大小",
"advanced_styling_field_upper_label_size_description": "调整输入框上方小标签和刻度标签的大小。",
"advanced_styling_field_upper_label_size_description": "调整输入框上方小标签和刻度标签的大小。",
"advanced_styling_field_upper_label_weight": "标签字体粗细",
"advanced_styling_field_upper_label_weight_description": "设置标签文字的粗细。",
"advanced_styling_field_upper_label_weight_description": "使标签更细或更粗。",
"advanced_styling_section_buttons": "按钮",
"advanced_styling_section_headlines": "标题和描述",
"advanced_styling_section_inputs": "输入项",
@@ -2966,12 +2956,14 @@
"improve_trial_conversion_question_1_subheader": "帮助 我们 更好 地 了解 你 :",
"improve_trial_conversion_question_2_button_label": "下一步",
"improve_trial_conversion_question_2_headline": "很抱歉 听到。使用 $[projectName] 时 最大的 问题 是 什么?",
"improve_trial_conversion_question_3_button_label": "下一步",
"improve_trial_conversion_question_3_headline": "您期望 $[projectName] 做什么?",
"improve_trial_conversion_question_4_button_label": "获取 20% 折扣",
"improve_trial_conversion_question_4_headline": "很抱歉 听到!首年 可 获 20% 优惠。",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>我们 很 乐意 为 您 提供 年 度 计划 20% 的 折扣。</span></p>",
"improve_trial_conversion_question_5_button_label": "下一步",
"improve_trial_conversion_question_5_headline": "你 想 实现 什么?",
"improve_trial_conversion_question_5_subheader": "请选择以下选项之一:",
"improve_trial_conversion_question_5_subheader": "请在下方描述:",
"improve_trial_conversion_question_6_headline": "你 现在 如何 解决 你的 问题?",
"improve_trial_conversion_question_6_subheader": "请 列举 替代 方案:",
"integration_setup_survey_description": "评估用户 添加 集成 到 产品 的 便捷程度 。 找到 盲点 。",
+7 -15
View File
@@ -342,7 +342,6 @@
"other": "其他",
"other_filters": "其他篩選條件",
"other_placeholder": "其他預設文字",
"others": "其他",
"overlay_color": "覆蓋層顏色",
"overview": "概覽",
"password": "密碼",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "請升級您的方案",
"powered_by_formbricks": "由 Formbricks 提供技術支援",
"preview": "預覽",
"preview_survey": "預覽問卷",
"privacy": "隱私權政策",
"product_manager": "產品經理",
"production": "正式環境",
@@ -495,7 +493,6 @@
"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} 個工作區的上限。",
@@ -1309,12 +1306,7 @@
"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": "啟用翻譯",
@@ -1367,7 +1359,7 @@
"audience": "受眾",
"auto_close_on_inactivity": "非活動時自動關閉",
"auto_progress_rating_and_nps": "自動前進評分與 NPS 問題",
"auto_progress_rating_and_nps_description": "當受訪者在評分或 NPS 問題中選擇答案時自動前進。此設定僅適用於單一問題區塊。必填問題會隱藏「下一步」按鈕;選填問題仍會顯示該按鈕以便跳過。",
"auto_progress_rating_and_nps_description": "在單一問題區塊中自動前進。必填問題會隱藏「下一步」按鈕,除非選擇了「其他」選項。",
"auto_save_disabled": "自動儲存已停用",
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
"auto_save_on": "自動儲存已啟用",
@@ -2066,7 +2058,6 @@
"downloading_qr_code": "正在下載 QR code",
"drop_offs": "放棄",
"drop_offs_tooltip": "問卷已開始但未完成的次數。",
"failed_to_copy_link": "無法複製連結",
"filter_added_successfully": "篩選器已成功新增",
"filter_updated_successfully": "篩選器已成功更新",
"filtered_responses_csv": "篩選回應 (CSV)",
@@ -2154,7 +2145,6 @@
},
"survey_deleted_successfully": "問卷已成功刪除!",
"survey_duplicated_successfully": "問卷已成功複製。",
"survey_duplication_error": "無法複製問卷。",
"templates": {
"all_channels": "所有管道",
"all_industries": "所有產業",
@@ -2312,11 +2302,11 @@
"advanced_styling_field_track_height": "軌道高度",
"advanced_styling_field_track_height_description": "調整進度條的厚度。",
"advanced_styling_field_upper_label_color": "標籤顏色",
"advanced_styling_field_upper_label_color_description": "設定輸入框上方小標籤和刻度標籤顏色。",
"advanced_styling_field_upper_label_color_description": "為輸入欄位上方小標籤和刻度標籤設定顏色。",
"advanced_styling_field_upper_label_size": "標籤字體大小",
"advanced_styling_field_upper_label_size_description": "調整輸入上方小標籤和刻度標籤的大小。",
"advanced_styling_field_upper_label_size_description": "調整輸入欄位上方小標籤和刻度標籤的字體大小。",
"advanced_styling_field_upper_label_weight": "標籤字體粗細",
"advanced_styling_field_upper_label_weight_description": "讓標籤字體變細或粗。",
"advanced_styling_field_upper_label_weight_description": "讓標籤變得更細或粗。",
"advanced_styling_section_buttons": "按鈕",
"advanced_styling_section_headlines": "標題與說明",
"advanced_styling_section_inputs": "輸入欄位",
@@ -2966,12 +2956,14 @@
"improve_trial_conversion_question_1_subheader": "協助我們更瞭解您:",
"improve_trial_conversion_question_2_button_label": "下一步",
"improve_trial_conversion_question_2_headline": "很抱歉聽到。使用 {projectName} 時,最大的問題是什麼?",
"improve_trial_conversion_question_3_button_label": "下一步",
"improve_trial_conversion_question_3_headline": "您期望 $[projectName] 做什麼?",
"improve_trial_conversion_question_4_button_label": "獲得 20% 折扣",
"improve_trial_conversion_question_4_headline": "很抱歉聽到!在第一年獲得 20% 的折扣。",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>我們很樂意為您提供年度方案的 20% 折扣。</span></p>",
"improve_trial_conversion_question_5_button_label": "下一步",
"improve_trial_conversion_question_5_headline": "您想要達成什麼?",
"improve_trial_conversion_question_5_subheader": "請選取以下其中一個選項",
"improve_trial_conversion_question_5_subheader": "請在下方說明",
"improve_trial_conversion_question_6_headline": "您現在如何解決您的問題?",
"improve_trial_conversion_question_6_subheader": "請列出替代解決方案:",
"integration_setup_survey_description": "評估使用者將整合新增至您的產品的容易程度。找出盲點。",
@@ -6,7 +6,6 @@ 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 {
@@ -26,9 +25,11 @@ const InfoIconButton = ({
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" aria-label={ariaLabel}>
<button
className="flex h-4 w-4 items-center justify-center rounded text-slate-500 hover:text-slate-700"
aria-label={ariaLabel}>
<Icon className="h-4 w-4" />
</Button>
</button>
</TooltipTrigger>
<TooltipContent avoidCollisions align="start" side="bottom" className={maxWidth}>
{tooltipContent}
@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { V3ApiError, getV3ApiErrorMessage, parseV3ApiError } from "@/modules/api/lib/v3-client";
describe("parseV3ApiError", () => {
test("parses RFC 9457 error responses into a typed V3ApiError", async () => {
const response = new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
requestId: "req_1",
invalid_params: [{ name: "surveyId", reason: "Invalid id" }],
}),
{
status: 403,
headers: {
"Content-Type": "application/problem+json",
"X-Request-Id": "req_1",
},
}
);
const error = await parseV3ApiError(response);
expect(error).toBeInstanceOf(V3ApiError);
expect(error.status).toBe(403);
expect(error.detail).toBe("You are not authorized to access this resource");
expect(error.code).toBe("forbidden");
expect(error.requestId).toBe("req_1");
expect(error.invalid_params).toEqual([{ name: "surveyId", reason: "Invalid id" }]);
});
test("falls back to a provided fallback message", () => {
expect(getV3ApiErrorMessage(new Error("boom"), "fallback")).toBe("boom");
expect(getV3ApiErrorMessage("bad", "fallback")).toBe("fallback");
});
});
+74
View File
@@ -0,0 +1,74 @@
export type TV3InvalidParam = {
name: string;
reason: string;
};
type TV3ProblemBody = {
status?: number;
detail?: string;
code?: string;
requestId?: string;
invalid_params?: TV3InvalidParam[];
};
export class V3ApiError extends Error {
status: number;
code?: string;
requestId?: string;
invalid_params?: TV3InvalidParam[];
constructor({
status,
detail,
code,
requestId,
invalid_params,
}: {
status: number;
detail: string;
code?: string;
requestId?: string;
invalid_params?: TV3InvalidParam[];
}) {
super(detail);
this.name = "V3ApiError";
this.status = status;
this.code = code;
this.requestId = requestId;
this.invalid_params = invalid_params;
}
get detail(): string {
return this.message;
}
}
export function getV3ApiErrorMessage(error: unknown, fallbackMessage: string): string {
if (error instanceof V3ApiError) {
return error.detail;
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallbackMessage;
}
export async function parseV3ApiError(response: Response): Promise<V3ApiError> {
let problemBody: TV3ProblemBody | undefined;
try {
problemBody = (await response.json()) as TV3ProblemBody;
} catch {
problemBody = undefined;
}
return new V3ApiError({
status: problemBody?.status ?? response.status,
detail: problemBody?.detail ?? response.statusText ?? "An unexpected error occurred.",
code: problemBody?.code,
requestId: problemBody?.requestId ?? response.headers.get("X-Request-Id") ?? undefined,
invalid_params: problemBody?.invalid_params,
});
}
@@ -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 } from "@/lib/utils/safe-identifier";
import { formatSnakeCaseToTitleCase, isSafeIdentifier, toSafeIdentifier } from "@/lib/utils/safe-identifier";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -57,25 +57,27 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
};
const handleNameChange = (value: string) => {
setFormData((prev) => ({ ...prev, name: value }));
if (keyError && formData.key) {
validateKey(formData.key);
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("");
}
}
};
const handleKeyChange = (value: string) => {
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,
};
});
setFormData((prev) => ({ ...prev, key: value }));
validateKey(value);
};
@@ -163,6 +165,17 @@ 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")}
@@ -177,17 +190,6 @@ 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 } from "@/modules/ui/components/dialog";
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
interface ProjectLimitModalProps {
@@ -17,6 +17,9 @@ 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
@@ -0,0 +1,140 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey } from "./surveys";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
},
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
const environmentId = "clq5n7p1q0000m7z0h5p6g3r3";
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
const mockDeletedSurveyAppPrivateSegment = {
id: surveyId,
environmentId,
type: "app",
segment: { id: segmentId, isPrivate: true },
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
};
const mockDeletedSurveyLink = {
id: surveyId,
environmentId,
type: "link",
segment: null,
triggers: [],
};
describe("deleteSurvey", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("should delete a link survey without a segment", async () => {
const deleteMock = vi.fn().mockResolvedValue(mockDeletedSurveyLink);
const segmentDeleteMock = vi.fn();
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
callback({
survey: { delete: deleteMock },
segment: { delete: segmentDeleteMock },
} as never)
);
const deletedSurvey = await deleteSurvey(surveyId);
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
expect(deleteMock).toHaveBeenCalledWith({
where: { id: surveyId },
include: {
segment: true,
triggers: { include: { actionClass: true } },
},
});
expect(segmentDeleteMock).not.toHaveBeenCalled();
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
});
test("should delete a private segment for app surveys", async () => {
const deleteMock = vi.fn().mockResolvedValue(mockDeletedSurveyAppPrivateSegment);
const segmentDeleteMock = vi.fn().mockResolvedValue({ id: segmentId });
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
callback({
survey: { delete: deleteMock },
segment: { delete: segmentDeleteMock },
} as never)
);
const deletedSurvey = await deleteSurvey(surveyId);
expect(segmentDeleteMock).toHaveBeenCalledWith({ where: { id: segmentId } });
expect(deletedSurvey).toEqual(mockDeletedSurveyAppPrivateSegment);
});
test("should map Prisma P2025 during survey deletion to ResourceNotFoundError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "4.0.0",
});
vi.mocked(prisma.$transaction).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(ResourceNotFoundError);
expect(logger.warn).toHaveBeenCalledWith({ surveyId }, "Survey not found during delete");
expect(logger.error).not.toHaveBeenCalled();
});
test("should handle non-P2025 PrismaClientKnownRequestError during survey deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Constraint failed", {
code: "P2003",
clientVersion: "4.0.0",
});
vi.mocked(prisma.$transaction).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
});
test("should handle generic errors during deletion", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.$transaction).mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
});
test("should throw validation error for invalid surveyId", async () => {
const invalidSurveyId = "invalid-id";
const validationError = new Error("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
});
+51
View File
@@ -0,0 +1,51 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, ZId]);
try {
return await prisma.$transaction(async (tx) => {
const deletedSurvey = await tx.survey.delete({
where: {
id: surveyId,
},
include: {
segment: true,
triggers: {
include: {
actionClass: true,
},
},
},
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
await tx.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
}
return deletedSurvey;
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2025") {
logger.warn({ surveyId }, "Survey not found during delete");
throw new ResourceNotFoundError("Survey", surveyId);
}
logger.error({ error, surveyId }, "Error deleting survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
+1 -127
View File
@@ -2,52 +2,18 @@
import { z } from "zod";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSurveyFilterCriteria } from "@formbricks/types/surveys/types";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
getEnvironmentIdFromSurveyId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromSurveyId,
getProjectIdFromEnvironmentId,
getProjectIdFromSurveyId,
} from "@/lib/utils/helper";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
import { getUserProjects } from "@/modules/survey/list/lib/project";
import {
copySurveyToOtherEnvironment,
deleteSurvey,
getSurvey,
getSurveys,
} from "@/modules/survey/list/lib/survey";
const ZGetSurveyAction = z.object({
surveyId: z.cuid2(),
});
export const getSurveyAction = authenticatedActionClient
.inputSchema(ZGetSurveyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
return await getSurvey(parsedInput.surveyId);
});
import { copySurveyToOtherEnvironment } from "@/modules/survey/list/lib/survey";
const ZCopySurveyToOtherEnvironmentAction = z.object({
surveyId: z.cuid2(),
@@ -127,62 +93,6 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient
})
);
const ZGetProjectsByEnvironmentIdAction = z.object({
environmentId: z.cuid2(),
});
export const getProjectsByEnvironmentIdAction = authenticatedActionClient
.inputSchema(ZGetProjectsByEnvironmentIdAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
return await getUserProjects(ctx.user.id, organizationId);
});
const ZDeleteSurveyAction = z.object({
surveyId: z.cuid2(),
});
export const deleteSurveyAction = authenticatedActionClient.inputSchema(ZDeleteSurveyAction).action(
withAuditLogging("deleted", "survey", async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = await getSurvey(parsedInput.surveyId);
return await deleteSurvey(parsedInput.surveyId);
})
);
const ZGenerateSingleUseIdAction = z.object({
surveyId: z.cuid2(),
isEncrypted: z.boolean(),
@@ -210,39 +120,3 @@ export const generateSingleUseIdsAction = authenticatedActionClient
return generateSurveySingleUseIds(parsedInput.count, parsedInput.isEncrypted);
});
const ZGetSurveysAction = z.object({
environmentId: z.cuid2(),
limit: z.number().optional(),
offset: z.number().optional(),
filterCriteria: ZSurveyFilterCriteria.optional(),
});
export const getSurveysAction = authenticatedActionClient
.inputSchema(ZGetSurveysAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
data: parsedInput.filterCriteria,
schema: ZSurveyFilterCriteria,
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
return await getSurveys(
parsedInput.environmentId,
parsedInput.limit,
parsedInput.offset,
parsedInput.filterCriteria
);
});
@@ -1,223 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertCircleIcon } from "lucide-react";
import { useFieldArray, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { TUserProject } from "@/modules/survey/list/types/projects";
import { TSurvey, TSurveyCopyFormData, ZSurveyCopyFormValidation } from "@/modules/survey/list/types/surveys";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import { FormControl, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
import { Label } from "@/modules/ui/components/label";
interface CopySurveyFormProps {
readonly defaultProjects: TUserProject[];
readonly survey: TSurvey;
readonly onCancel: () => void;
readonly setOpen: (value: boolean) => void;
}
interface EnvironmentCheckboxProps {
readonly environmentId: string;
readonly environmentType: string;
readonly fieldValue: string[];
readonly onChange: (value: string[]) => void;
}
function EnvironmentCheckbox({
environmentId,
environmentType,
fieldValue,
onChange,
}: EnvironmentCheckboxProps) {
const handleCheckedChange = () => {
if (fieldValue.includes(environmentId)) {
onChange(fieldValue.filter((id) => id !== environmentId));
} else {
onChange([...fieldValue, environmentId]);
}
};
return (
<FormItem>
<div className="flex items-center">
<FormControl>
<div className="flex items-center">
<Checkbox
type="button"
checked={fieldValue.includes(environmentId)}
onCheckedChange={handleCheckedChange}
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
id={environmentId}
/>
<Label htmlFor={environmentId}>
<p className="text-sm font-medium capitalize text-slate-900">{environmentType}</p>
</Label>
</div>
</FormControl>
</div>
</FormItem>
);
}
interface EnvironmentCheckboxGroupProps {
readonly project: TUserProject;
readonly form: ReturnType<typeof useForm<TSurveyCopyFormData>>;
readonly projectIndex: number;
}
function EnvironmentCheckboxGroup({ project, form, projectIndex }: EnvironmentCheckboxGroupProps) {
return (
<div className="flex flex-col gap-4">
{project.environments.map((environment) => (
<FormField
key={environment.id}
control={form.control}
name={`projects.${projectIndex}.environments`}
render={({ field }) => (
<EnvironmentCheckbox
environmentId={environment.id}
environmentType={environment.type}
fieldValue={field.value}
onChange={field.onChange}
/>
)}
/>
))}
</div>
);
}
export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: CopySurveyFormProps) => {
const { t } = useTranslation();
const filteredProjects = defaultProjects.map((project) => ({
...project,
environments: project.environments.filter((env) => env.id !== survey.environmentId),
}));
const form = useForm<TSurveyCopyFormData>({
resolver: zodResolver(ZSurveyCopyFormValidation),
defaultValues: {
projects: filteredProjects.map((project) => ({
project: project.id,
environments: [],
})),
},
});
const formFields = useFieldArray({
name: "projects",
control: form.control,
});
async function onSubmit(data: TSurveyCopyFormData) {
const filteredData = data.projects.filter((project) => project.environments.length > 0);
try {
const copyOperationsWithMetadata = filteredData.flatMap((projectData) => {
const project = filteredProjects.find((p) => p.id === projectData.project);
return projectData.environments.map((environmentId) => {
const environment =
project?.environments[0]?.id === environmentId
? project?.environments[0]
: project?.environments[1];
return {
projectName: project?.name ?? "Unknown Project",
environmentType: environment?.type ?? "unknown",
environmentId,
};
});
});
const results: Awaited<ReturnType<typeof copySurveyToOtherEnvironmentAction>>[] = [];
for (const item of copyOperationsWithMetadata) {
const result = await copySurveyToOtherEnvironmentAction({
surveyId: survey.id,
targetEnvironmentId: item.environmentId,
});
results.push(result);
}
let successCount = 0;
let errorCount = 0;
const errorsIndexes: number[] = [];
results.forEach((result, index) => {
if (result?.data) {
successCount++;
} else {
errorsIndexes.push(index);
errorCount++;
}
});
if (successCount > 0) {
if (errorCount === 0) {
toast.success(t("environments.surveys.copy_survey_success"));
} else {
toast.error(
t("environments.surveys.copy_survey_partially_success", {
success: successCount,
error: errorCount,
}),
{
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
}
);
}
}
if (errorsIndexes.length > 0) {
errorsIndexes.forEach((index, idx) => {
const { projectName, environmentType } = copyOperationsWithMetadata[index];
const result = results[index];
const errorMessage = getFormattedErrorMessage(result);
toast.error(`[${projectName}] - [${environmentType}] - ${errorMessage}`, {
duration: 2000 + 2000 * idx,
});
});
}
} catch (error) {
toast.error(t("environments.surveys.copy_survey_error"));
} finally {
setOpen(false);
}
}
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex h-full w-full flex-col bg-white">
<div className="flex-1 space-y-8 overflow-y-auto">
{formFields.fields.map((field, projectIndex) => {
const project = filteredProjects.find((project) => project.id === field.project);
if (!project) return null;
return (
<div key={project.id}>
<div className="flex flex-col gap-4">
<div className="w-fit">
<p className="text-base font-semibold text-slate-900">{project.name}</p>
</div>
<EnvironmentCheckboxGroup project={project} form={form} projectIndex={projectIndex} />
</div>
</div>
);
})}
</div>
<div className="sticky bottom-0 flex justify-end space-x-2 bg-white pt-4">
<Button type="button" onClick={onCancel} variant="secondary">
{t("common.cancel")}
</Button>
<Button type="submit">{t("environments.surveys.copy_survey")}</Button>
</div>
</form>
</FormProvider>
);
};
@@ -1,44 +0,0 @@
"use client";
import { MousePointerClickIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import SurveyCopyOptions from "./survey-copy-options";
interface CopySurveyModalProps {
open: boolean;
setOpen: (value: boolean) => void;
survey: TSurvey;
}
export const CopySurveyModal = ({ open, setOpen, survey }: CopySurveyModalProps) => {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[600px]">
<DialogHeader>
<MousePointerClickIcon />
<DialogTitle>{t("environments.surveys.copy_survey")}</DialogTitle>
<DialogDescription>{t("environments.surveys.copy_survey_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<SurveyCopyOptions
survey={survey}
environmentId={survey.environmentId}
onCancel={() => setOpen(false)}
setOpen={setOpen}
/>
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -1,11 +1,12 @@
"use client";
import { TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
import { TSortOption } from "@formbricks/types/surveys/types";
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { DropdownMenuItem } from "@/modules/ui/components/dropdown-menu";
interface SortOptionProps {
option: TSortOption;
sortBy: TSurveyFilters["sortBy"];
sortBy: TSurveyOverviewFilters["sortBy"];
handleSortChange: (option: TSortOption) => void;
}
@@ -8,27 +8,25 @@ import { cn } from "@/lib/cn";
import { timeSince } from "@/lib/time";
import { formatDateForDisplay } from "@/lib/utils/datetime";
import { SurveyTypeIndicator } from "@/modules/survey/list/components/survey-type-indicator";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { TSurveyListItem } from "@/modules/survey/list/types/survey-overview";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { SurveyDropDownMenu } from "./survey-dropdown-menu";
interface SurveyCardProps {
survey: TSurvey;
survey: TSurveyListItem;
environmentId: string;
isReadOnly: boolean;
publicDomain: string;
deleteSurvey: (surveyId: string) => void;
isReadOnly: boolean;
deleteSurvey: (surveyId: string) => Promise<void>;
locale: TUserLocale;
onSurveysCopied?: () => void;
}
export const SurveyCard = ({
survey,
environmentId,
isReadOnly,
publicDomain,
isReadOnly,
deleteSurvey,
locale,
onSurveysCopied,
}: SurveyCardProps) => {
const { t } = useTranslation();
const surveyStatusLabel = (() => {
@@ -56,43 +54,53 @@ export const SurveyCard = ({
const isDraftAndReadOnly = survey.status === "draft" && isReadOnly;
const CardContent = (
<>
const CardBody = (
<div
className={cn(
"grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 pr-8 shadow-sm transition-colors ease-in-out",
!isDraftAndReadOnly && "hover:border-slate-400"
)}>
<div className="col-span-2 flex max-w-full items-center justify-self-start text-sm font-medium text-slate-900">
<div className="w-full truncate">{survey.name}</div>
</div>
<div
className={cn(
"grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 pr-8 shadow-sm transition-colors ease-in-out",
!isDraftAndReadOnly && "hover:border-slate-400"
"col-span-1 flex w-fit items-center gap-2 whitespace-nowrap rounded-full py-1 pl-1 pr-2 text-sm text-slate-800",
survey.status === "inProgress" && "bg-emerald-50",
survey.status === "completed" && "bg-slate-200",
survey.status === "draft" && "bg-slate-100",
survey.status === "paused" && "bg-slate-100"
)}>
<div className="col-span-2 flex max-w-full items-center justify-self-start text-sm font-medium text-slate-900">
<div className="w-full truncate">{survey.name}</div>
</div>
<div
className={cn(
"col-span-1 flex w-fit items-center gap-2 whitespace-nowrap rounded-full py-1 pl-1 pr-2 text-sm text-slate-800",
surveyStatusLabel === "In Progress" && "bg-emerald-50",
surveyStatusLabel === "Completed" && "bg-slate-200",
surveyStatusLabel === "Draft" && "bg-slate-100",
surveyStatusLabel === "Paused" && "bg-slate-100"
)}>
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}{" "}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{survey.responseCount}
</div>
<div className="col-span-1 flex justify-between">
<SurveyTypeIndicator type={survey.type} />
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{formatDateForDisplay(survey.createdAt, locale)}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{timeSince(survey.updatedAt.toString(), locale)}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{survey.creator ? survey.creator.name : "-"}
</div>
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}{" "}
</div>
<button className="absolute right-3 top-3.5" onClick={(e) => e.stopPropagation()}>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{survey.responseCount}
</div>
<div className="col-span-1 flex justify-between">
<SurveyTypeIndicator type={survey.type} />
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{formatDateForDisplay(survey.createdAt, locale)}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{timeSince(survey.updatedAt.toString(), locale)}
</div>
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
{survey.creator ? survey.creator.name : "-"}
</div>
</div>
);
return (
<div className="relative block">
{isDraftAndReadOnly ? (
CardBody
) : (
<Link href={linkHref} key={survey.id} className="block">
{CardBody}
</Link>
)}
<div className="absolute right-3 top-3.5">
<SurveyDropDownMenu
survey={survey}
key={`surveys-${survey.id}`}
@@ -101,17 +109,8 @@ export const SurveyCard = ({
disabled={isDraftAndReadOnly}
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
deleteSurvey={deleteSurvey}
onSurveysCopied={onSurveysCopied}
/>
</button>
</>
);
return isDraftAndReadOnly ? (
<div className="relative block">{CardContent}</div>
) : (
<Link href={linkHref} key={survey.id} className="relative block">
{CardContent}
</Link>
</div>
</div>
);
};
@@ -1,50 +0,0 @@
"use client";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getProjectsByEnvironmentIdAction } from "@/modules/survey/list/actions";
import { TUserProject } from "@/modules/survey/list/types/projects";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { CopySurveyForm } from "./copy-survey-form";
interface SurveyCopyOptionsProps {
survey: TSurvey;
environmentId: string;
onCancel: () => void;
setOpen: (value: boolean) => void;
}
const SurveyCopyOptions = ({ environmentId, survey, onCancel, setOpen }: SurveyCopyOptionsProps) => {
const [projects, setProjects] = useState<TUserProject[]>([]);
const [projectLoading, setProjectLoading] = useState(true);
useEffect(() => {
const fetchProjects = async () => {
const getProjectsByEnvironmentIdResponse = await getProjectsByEnvironmentIdAction({ environmentId });
if (getProjectsByEnvironmentIdResponse?.data) {
setProjects(getProjectsByEnvironmentIdResponse?.data);
} else {
const errorMessage = getFormattedErrorMessage(getProjectsByEnvironmentIdResponse);
toast.error(errorMessage);
}
setProjectLoading(false);
};
fetchProjects();
}, [environmentId]);
if (projectLoading) {
return (
<div className="relative flex h-full min-h-96 w-full items-center justify-center bg-white pb-12">
<Loader2 className="animate-spin" />
</div>
);
}
return <CopySurveyForm defaultProjects={projects} survey={survey} onCancel={onCancel} setOpen={setOpen} />;
};
export default SurveyCopyOptions;
@@ -1,14 +1,6 @@
"use client";
import {
ArrowUpFromLineIcon,
CopyIcon,
EyeIcon,
LinkIcon,
MoreVertical,
SquarePenIcon,
TrashIcon,
} from "lucide-react";
import { EyeIcon, LinkIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
@@ -16,15 +8,10 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import {
copySurveyToOtherEnvironmentAction,
deleteSurveyAction,
getSurveyAction,
} from "@/modules/survey/list/actions";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { TSurveyListItem } from "@/modules/survey/list/types/survey-overview";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
@@ -33,16 +20,14 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { CopySurveyModal } from "./copy-survey-modal";
interface SurveyDropDownMenuProps {
environmentId: string;
survey: TSurvey;
survey: TSurveyListItem;
publicDomain: string;
disabled?: boolean;
isSurveyCreationDeletionDisabled?: boolean;
deleteSurvey: (surveyId: string) => void;
onSurveysCopied?: () => void;
deleteSurvey: (surveyId: string) => Promise<void>;
}
export const SurveyDropDownMenu = ({
@@ -52,32 +37,29 @@ export const SurveyDropDownMenu = ({
disabled,
isSurveyCreationDeletionDisabled,
deleteSurvey,
onSurveysCopied,
}: SurveyDropDownMenuProps) => {
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const router = useRouter();
const surveyLink = useMemo(() => publicDomain + "/s/" + survey.id, [survey.id, publicDomain]);
const editHref = `/environments/${environmentId}/surveys/${survey.id}/edit`;
const surveyLink = useMemo(() => `${publicDomain}/s/${survey.id}`, [publicDomain, survey.id]);
const isSingleUseEnabled = survey.singleUse?.enabled ?? false;
const canManageSurvey = !isSurveyCreationDeletionDisabled;
const canPreviewOrCopyLink = survey.type === "link" && survey.status !== "draft";
const hasVisibleActions = canManageSurvey || canPreviewOrCopyLink;
const handleDeleteSurvey = async (surveyId: string) => {
setLoading(true);
try {
const result = await deleteSurveyAction({ surveyId });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
deleteSurvey(surveyId);
await deleteSurvey(surveyId);
toast.success(t("environments.surveys.survey_deleted_successfully"));
} catch (error) {
toast.error(t("environments.surveys.error_deleting_survey"));
toast.error(getV3ApiErrorMessage(error, t("environments.surveys.error_deleting_survey")));
} finally {
setLoading(false);
}
@@ -87,147 +69,93 @@ export const SurveyDropDownMenu = ({
try {
e.preventDefault();
setIsDropDownOpen(false);
// For single-use surveys, this button is disabled, so we just copy the base link
const copiedLink = copySurveyLink(surveyLink);
navigator.clipboard.writeText(copiedLink);
await navigator.clipboard.writeText(copySurveyLink(surveyLink));
toast.success(t("common.copied_to_clipboard"));
} catch (error) {
logger.error(error);
toast.error(t("environments.surveys.summary.failed_to_copy_link"));
toast.error(t("common.something_went_wrong_please_try_again"));
}
};
const duplicateSurveyAndRefresh = async (surveyId: string) => {
setLoading(true);
try {
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
surveyId,
targetEnvironmentId: environmentId,
});
if (duplicatedSurveyResponse?.serverError) {
toast.error(getFormattedErrorMessage(duplicatedSurveyResponse));
} else if (duplicatedSurveyResponse?.data) {
const transformedDuplicatedSurvey = await getSurveyAction({
surveyId: duplicatedSurveyResponse.data.id,
});
if (transformedDuplicatedSurvey?.data) {
onSurveysCopied?.();
}
toast.success(t("environments.surveys.survey_duplicated_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(duplicatedSurveyResponse);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("environments.surveys.survey_duplication_error"));
}
setLoading(false);
};
const handleEditforActiveSurvey = (e: React.MouseEvent) => {
e.preventDefault();
setIsDropDownOpen(false);
setIsCautionDialogOpen(true);
};
if (!hasVisibleActions) {
return null;
}
return (
<div
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
data-testid="survey-dropdown-menu">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
<div
<button
type="button"
data-testid="survey-dropdown-trigger"
aria-label={t("environments.surveys.open_options")}
className={cn(
"rounded-lg border bg-white p-2",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-slate-50"
)}>
<span className="sr-only">{t("environments.surveys.open_options")}</span>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="inline-block w-auto min-w-max">
<DropdownMenuGroup>
{!isSurveyCreationDeletionDisabled && (
<>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}
onClick={survey.responseCount > 0 ? handleEditforActiveSurvey : undefined}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
duplicateSurveyAndRefresh(survey.id);
}}>
<CopyIcon className="mr-2 h-4 w-4" />
{t("common.duplicate")}
</button>
</DropdownMenuItem>
</>
{canManageSurvey && (
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={editHref}
onClick={survey.responseCount > 0 ? handleEditforActiveSurvey : undefined}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</Link>
</DropdownMenuItem>
)}
{!isSurveyCreationDeletionDisabled && (
{canPreviewOrCopyLink && (
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
disabled={loading}
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setIsCopyFormOpen(true);
const previewUrl = new URL(surveyLink);
previewUrl.searchParams.set("preview", "true");
globalThis.window.open(previewUrl.toString(), "_blank");
}}>
<ArrowUpFromLineIcon className="mr-2 h-4 w-4" />
{t("common.copy")}...
<EyeIcon className="mr-2 h-4 w-4" />
{t("common.preview")}
</button>
</DropdownMenuItem>
)}
{survey.type === "link" && survey.status !== "draft" && (
<>
<DropdownMenuItem>
<button
type="button"
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
onClick={async (e) => {
e.preventDefault();
setIsDropDownOpen(false);
const previewUrl = surveyLink + "?preview=true";
window.open(previewUrl, "_blank");
}}>
<EyeIcon className="mr-2 h-4 w-4" />
{t("common.preview_survey")}
</button>
</DropdownMenuItem>
<DropdownMenuItem>
<button
type="button"
data-testid="copy-link"
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
onClick={async (e) => handleCopyLink(e)}>
<LinkIcon className="mr-2 h-4 w-4" />
{t("common.copy_link")}
</button>
</DropdownMenuItem>
</>
{canPreviewOrCopyLink && (
<DropdownMenuItem>
<button
type="button"
data-testid="copy-link"
className={cn(
"flex w-full items-center",
isSingleUseEnabled && "cursor-not-allowed opacity-50"
)}
disabled={isSingleUseEnabled}
onClick={handleCopyLink}>
<LinkIcon className="mr-2 h-4 w-4" />
{t("common.copy_link")}
</button>
</DropdownMenuItem>
)}
{!isSurveyCreationDeletionDisabled && (
{canManageSurvey && (
<DropdownMenuItem>
<button
type="button"
@@ -246,7 +174,7 @@ export const SurveyDropDownMenu = ({
</DropdownMenuContent>
</DropdownMenu>
{!isSurveyCreationDeletionDisabled && (
{canManageSurvey && (
<DeleteDialog
deleteWhat={t("common.survey")}
open={isDeleteDialogOpen}
@@ -257,26 +185,20 @@ export const SurveyDropDownMenu = ({
/>
)}
{survey.responseCount > 0 && (
{canManageSurvey && survey.responseCount > 0 && (
<EditPublicSurveyAlertDialog
open={isCautionDialogOpen}
setOpen={setIsCautionDialogOpen}
isLoading={loading}
primaryButtonAction={async () => {
await duplicateSurveyAndRefresh(survey.id);
setIsCautionDialogOpen(false);
router.push(editHref);
}}
primaryButtonText={t("common.duplicate")}
secondaryButtonAction={() =>
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`)
}
secondaryButtonText={t("common.edit")}
primaryButtonText={t("common.edit")}
secondaryButtonAction={() => setIsCautionDialogOpen(false)}
secondaryButtonText={t("common.cancel")}
/>
)}
{isCopyFormOpen && (
<CopySurveyModal open={isCopyFormOpen} setOpen={setIsCopyFormOpen} survey={survey} />
)}
</div>
);
};
@@ -12,7 +12,7 @@ import {
interface SurveyFilterDropdownProps {
title: string;
id: "createdBy" | "status" | "type";
id: "status" | "type";
options: TFilterOption[];
selectedOptions: string[];
setSelectedOptions: (value: string) => void;
@@ -2,14 +2,14 @@
import { TFunction } from "i18next";
import { ChevronDownIcon, X } from "lucide-react";
import { useState } from "react";
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDebounce } from "react-use";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TFilterOption, TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import type { TProjectConfigChannel } from "@formbricks/types/project";
import type { TFilterOption, TSortOption } from "@formbricks/types/surveys/types";
import { SortOption } from "@/modules/survey/list/components/sort-option";
import { initialFilters } from "@/modules/survey/list/lib/constants";
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
@@ -20,16 +20,11 @@ import { SearchBar } from "@/modules/ui/components/search-bar";
import { SurveyFilterDropdown } from "./survey-filter-dropdown";
interface SurveyFilterProps {
surveyFilters: TSurveyFilters;
setSurveyFilters: React.Dispatch<React.SetStateAction<TSurveyFilters>>;
surveyFilters: TSurveyOverviewFilters;
setSurveyFilters: Dispatch<SetStateAction<TSurveyOverviewFilters>>;
currentProjectChannel: TProjectConfigChannel;
}
const getCreatorOptions = (t: TFunction): TFilterOption[] => [
{ label: t("common.you"), value: "you" },
{ label: t("common.others"), value: "others" },
];
const getStatusOptions = (t: TFunction): TFilterOption[] => [
{ label: t("common.draft"), value: "draft" },
{ label: t("common.in_progress"), value: "inProgress" },
@@ -61,10 +56,10 @@ export const SurveyFilters = ({
setSurveyFilters,
currentProjectChannel,
}: SurveyFilterProps) => {
const { createdBy, sortBy, status, type } = surveyFilters;
const [name, setName] = useState("");
const { sortBy, status, type } = surveyFilters;
const [name, setName] = useState(surveyFilters.name);
const { t } = useTranslation();
useDebounce(() => setSurveyFilters((prev) => ({ ...prev, name: name })), 800, [name]);
useDebounce(() => setSurveyFilters((prev) => ({ ...prev, name })), 800, [name]);
const [dropdownOpenStates, setDropdownOpenStates] = useState(new Map());
@@ -73,20 +68,14 @@ export const SurveyFilters = ({
{ label: t("common.app"), value: "app" },
];
useEffect(() => {
setName(surveyFilters.name);
}, [surveyFilters.name]);
const toggleDropdown = (id: string) => {
setDropdownOpenStates(new Map(dropdownOpenStates).set(id, !dropdownOpenStates.get(id)));
};
const handleCreatedByChange = (value: string) => {
if (value === "you" || value === "others") {
if (createdBy.includes(value)) {
setSurveyFilters((prev) => ({ ...prev, createdBy: prev.createdBy.filter((v) => v !== value) }));
} else {
setSurveyFilters((prev) => ({ ...prev, createdBy: [...prev.createdBy, value] }));
}
}
};
const handleStatusChange = (value: string) => {
if (value === "inProgress" || value === "paused" || value === "completed" || value === "draft") {
if (status.includes(value)) {
@@ -120,17 +109,6 @@ export const SurveyFilters = ({
placeholder={t("environments.surveys.search_by_survey_name")}
className="border-slate-700"
/>
<div>
<SurveyFilterDropdown
title={t("common.created_by")}
id="createdBy"
options={getCreatorOptions(t)}
selectedOptions={createdBy}
setSelectedOptions={handleCreatedByChange}
isOpen={dropdownOpenStates.get("createdBy")}
toggleDropdown={toggleDropdown}
/>
</div>
<div>
<SurveyFilterDropdown
title={t("common.status")}
@@ -156,13 +134,12 @@ export const SurveyFilters = ({
</div>
)}
{(createdBy.length > 0 || status.length > 0 || type.length > 0 || name) && (
{(status.length > 0 || type.length > 0 || name) && (
<Button
size="sm"
onClick={() => {
setSurveyFilters(initialFilters);
setName(""); // Also clear the search input
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
setName(initialFilters.name);
}}
className="h-8">
{t("common.clear_filters")}
@@ -1,195 +1,244 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { PlusIcon } from "lucide-react";
import Link from "next/link";
import { type ComponentProps, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { wrapThrows } from "@formbricks/types/error-handlers";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TSurveyFilters } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { getSurveysAction } from "@/modules/survey/list/actions";
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
import { useDeleteSurvey } from "@/modules/survey/list/hooks/use-delete-survey";
import { useSurveys } from "@/modules/survey/list/hooks/use-surveys";
import { initialFilters } from "@/modules/survey/list/lib/constants";
import { getFormattedFilters } from "@/modules/survey/list/lib/utils";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import {
hasActiveSurveyFilters,
normalizeSurveyFilters,
parseStoredSurveyFilters,
} from "@/modules/survey/list/lib/utils";
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { TemplateContainerWithPreview } from "@/modules/survey/templates/components/template-container";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SurveyCard } from "./survey-card";
import { SurveyFilters } from "./survey-filters";
import { SurveyLoading } from "./survey-loading";
interface SurveysListProps {
environmentId: string;
isReadOnly: boolean;
publicDomain: string;
environment: ComponentProps<typeof TemplateContainerWithPreview>["environment"];
project: ComponentProps<typeof TemplateContainerWithPreview>["project"];
userId: string;
publicDomain: string;
isReadOnly: boolean;
surveysPerPage: number;
currentProjectChannel: TProjectConfigChannel;
locale: TUserLocale;
}
export const SurveysList = ({
environmentId,
isReadOnly,
publicDomain,
environment,
project,
userId,
surveysPerPage: surveysLimit,
publicDomain,
isReadOnly,
surveysPerPage,
currentProjectChannel,
locale,
}: SurveysListProps) => {
const router = useRouter();
const [surveys, setSurveys] = useState<TSurvey[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [hasMore, setHasMore] = useState<boolean>(false);
const [refreshTrigger, setRefreshTrigger] = useState(false);
const { t } = useTranslation();
const [surveyFilters, setSurveyFilters] = useState<TSurveyFilters>(initialFilters);
const [surveyFilters, setSurveyFilters] = useState<TSurveyOverviewFilters>(initialFilters);
const [isFilterInitialized, setIsFilterInitialized] = useState(false);
const { name, createdBy, status, type, sortBy } = surveyFilters;
const filters = useMemo(
() => getFormattedFilters(surveyFilters, userId),
[name, JSON.stringify(createdBy), JSON.stringify(status), JSON.stringify(type), sortBy, userId]
);
const [parent] = useAutoAnimate();
useEffect(() => {
if (typeof window !== "undefined") {
const savedFilters = localStorage.getItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
if (savedFilters) {
const surveyParseResult = wrapThrows(() => JSON.parse(savedFilters))();
if (!surveyParseResult.ok) {
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
setSurveyFilters(initialFilters);
} else {
setSurveyFilters(surveyParseResult.data);
}
}
setIsFilterInitialized(true);
if (typeof globalThis.window === "undefined") {
return;
}
}, []);
const storedFilters = globalThis.window.localStorage.getItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
const parsedFilters = parseStoredSurveyFilters(storedFilters, currentProjectChannel);
if (storedFilters && !parsedFilters) {
globalThis.window.localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
setSurveyFilters(initialFilters);
} else if (parsedFilters) {
setSurveyFilters(parsedFilters);
}
setIsFilterInitialized(true);
}, [currentProjectChannel]);
const normalizedFilters = useMemo(
() => normalizeSurveyFilters(surveyFilters, currentProjectChannel),
[currentProjectChannel, surveyFilters]
);
useEffect(() => {
if (isFilterInitialized) {
localStorage.setItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS, JSON.stringify(surveyFilters));
if (!isFilterInitialized || typeof globalThis.window === "undefined") {
return;
}
}, [surveyFilters, isFilterInitialized]);
useEffect(() => {
// Wait for filters to be loaded from localStorage before fetching
if (!isFilterInitialized) return;
globalThis.window.localStorage.setItem(
FORMBRICKS_SURVEYS_FILTERS_KEY_LS,
JSON.stringify(normalizedFilters)
);
}, [normalizedFilters, isFilterInitialized]);
const fetchFilteredSurveys = async () => {
setIsFetching(true);
const res = await getSurveysAction({
environmentId,
limit: surveysLimit,
offset: undefined,
filterCriteria: filters,
});
if (res?.data) {
if (res.data.length < surveysLimit) {
setHasMore(false);
} else {
setHasMore(true);
}
setSurveys(res.data);
setIsFetching(false);
}
};
fetchFilteredSurveys();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [environmentId, surveysLimit, filters, refreshTrigger, isFilterInitialized]);
const {
error,
fetchNextPage,
hasNextPage,
isError,
isFetchingNextPage,
isLoading,
queryKey,
refetch,
surveys,
totalCount,
} = useSurveys({
workspaceId: environment.id,
limit: surveysPerPage,
filters: normalizedFilters,
enabled: isFilterInitialized,
});
const fetchNextPage = useCallback(async () => {
setIsFetching(true);
const res = await getSurveysAction({
environmentId,
limit: surveysLimit,
offset: surveys.length,
filterCriteria: filters,
});
if (res?.data) {
if (res.data.length === 0 || res.data.length < surveysLimit) {
setHasMore(false);
} else {
setHasMore(true);
}
const deleteSurveyMutation = useDeleteSurvey({ queryKey });
setSurveys([...surveys, ...res.data]);
setIsFetching(false);
}
}, [environmentId, surveys, surveysLimit, filters]);
const hasAppliedFilters = hasActiveSurveyFilters(normalizedFilters);
const showInitialLoading = !isFilterInitialized || (isLoading && surveys.length === 0);
const showTemplateEmptyState = !isError && totalCount === 0 && !hasAppliedFilters && !isReadOnly;
const showReadOnlyEmptyState = !isError && totalCount === 0 && !hasAppliedFilters && isReadOnly;
const handleDeleteSurvey = async (surveyId: string) => {
const newSurveys = surveys.filter((survey) => survey.id !== surveyId);
setSurveys(newSurveys);
if (newSurveys.length === 0) {
setIsFetching(true);
router.refresh();
}
await deleteSurveyMutation.mutateAsync({ surveyId });
};
const triggerRefresh = useCallback(() => {
setRefreshTrigger((prev) => !prev);
}, []);
const createSurveyButton = (
<Button size="sm" asChild>
<Link href={`/environments/${environment.id}/surveys/templates`}>
{t("environments.surveys.new_survey")}
<PlusIcon />
</Link>
</Button>
);
return (
<div className="space-y-6">
<SurveyFilters
surveyFilters={surveyFilters}
setSurveyFilters={setSurveyFilters}
currentProjectChannel={currentProjectChannel}
/>
{surveys.length > 0 ? (
<div>
<div className="flex-col space-y-3" ref={parent}>
<div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 pr-8 text-sm text-slate-800">
<div className="col-span-2 place-self-start">{t("common.name")}</div>
<div className="col-span-1">{t("common.status")}</div>
<div className="col-span-1">{t("common.responses")}</div>
<div className="col-span-1">{t("common.type")}</div>
<div className="col-span-1">{t("common.created_at")}</div>
<div className="col-span-1">{t("common.updated_at")}</div>
<div className="col-span-1">{t("common.created_by")}</div>
</div>
{surveys.map((survey) => {
return (
<SurveyCard
key={survey.id}
survey={survey}
environmentId={environmentId}
isReadOnly={isReadOnly}
publicDomain={publicDomain}
deleteSurvey={handleDeleteSurvey}
locale={locale}
onSurveysCopied={triggerRefresh}
/>
);
})}
if (showInitialLoading) {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.surveys")} />
<div className="flex items-center justify-between">
<div className="flex h-9 animate-pulse gap-2">
<div className="w-48 rounded-md bg-slate-300"></div>
{[1, 2, 3].map((i) => (
<div key={i} className="w-24 rounded-md bg-slate-300"></div>
))}
</div>
<div className="flex h-9 animate-pulse gap-2">
<div className="w-36 rounded-md bg-slate-300"></div>
</div>
</div>
<SurveyLoading />
</PageContentWrapper>
);
}
{hasMore && (
<div className="flex justify-center py-5">
<Button onClick={fetchNextPage} variant="secondary" size="sm" loading={isFetching}>
{t("common.load_more")}
</Button>
</div>
)}
</div>
) : (
<div className="flex h-full w-full">
{isFetching ? (
<SurveyLoading />
) : (
<div className="flex w-full flex-col items-center justify-center text-slate-600">
<span className="h-24 w-24 p-4 text-center text-5xl">🕵</span>
{t("common.no_surveys_found")}
</div>
)}
</div>
)}
if (showTemplateEmptyState) {
return (
<TemplateContainerWithPreview
userId={userId}
environment={environment}
project={project}
isTemplatePage={false}
publicDomain={publicDomain}
/>
);
}
if (showReadOnlyEmptyState) {
return (
<PageContentWrapper>
<h1 className="px-6 text-3xl font-extrabold text-slate-700">
{t("environments.surveys.no_surveys_created_yet")}
</h1>
<h2 className="px-6 text-lg font-medium text-slate-500">
{t("environments.surveys.read_only_user_not_allowed_to_create_survey_warning")}
</h2>
</PageContentWrapper>
);
}
let surveyContent = (
<div className="flex h-full w-full">
<div className="flex w-full flex-col items-center justify-center text-slate-600">
<span className="h-24 w-24 p-4 text-center text-5xl">🕵</span>
{t("common.no_surveys_found")}
</div>
</div>
);
if (isError && surveys.length === 0) {
surveyContent = (
<div className="flex w-full flex-col items-center justify-center gap-4 py-16 text-slate-600">
<p>{getV3ApiErrorMessage(error, t("common.something_went_wrong_please_try_again"))}</p>
<Button variant="secondary" size="sm" onClick={() => refetch()}>
{t("common.try_again")}
</Button>
</div>
);
} else if (surveys.length > 0) {
surveyContent = (
<div>
<div className="flex-col space-y-3" ref={parent}>
<div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 pr-8 text-sm text-slate-800">
<div className="col-span-2 place-self-start">{t("common.name")}</div>
<div className="col-span-1">{t("common.status")}</div>
<div className="col-span-1">{t("common.responses")}</div>
<div className="col-span-1">{t("common.type")}</div>
<div className="col-span-1">{t("common.created_at")}</div>
<div className="col-span-1">{t("common.updated_at")}</div>
<div className="col-span-1">{t("common.created_by")}</div>
</div>
{surveys.map((survey) => (
<SurveyCard
key={survey.id}
survey={survey}
environmentId={environment.id}
isReadOnly={isReadOnly}
deleteSurvey={handleDeleteSurvey}
publicDomain={publicDomain}
locale={locale}
/>
))}
</div>
{hasNextPage && (
<div className="flex justify-center py-5">
<Button
onClick={() => fetchNextPage()}
variant="secondary"
size="sm"
loading={isFetchingNextPage}>
{t("common.load_more")}
</Button>
</div>
)}
</div>
);
}
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : createSurveyButton} />
<div className="space-y-6">
<SurveyFilters
surveyFilters={normalizedFilters}
setSurveyFilters={setSurveyFilters}
currentProjectChannel={currentProjectChannel}
/>
{surveyContent}
</div>
</PageContentWrapper>
);
};
@@ -0,0 +1,163 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, createElement } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { surveyKeys } from "@/modules/survey/list/lib/query";
import { TSurveyListPage } from "@/modules/survey/list/lib/v3-surveys-client";
import { useDeleteSurvey } from "./use-delete-survey";
function createWrapper(queryClient: QueryClient) {
const Wrapper = ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
Wrapper.displayName = "UseDeleteSurveyTestWrapper";
return Wrapper;
}
function createQueryData(): { pages: TSurveyListPage[]; pageParams: (string | null)[] } {
return {
pages: [
{
data: [
{
id: "survey_1",
name: "Survey 1",
workspaceId: "env_1",
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "Alice" },
singleUse: null,
},
],
meta: {
limit: 20,
nextCursor: null,
totalCount: 1,
},
},
],
pageParams: [null],
};
}
describe("useDeleteSurvey", () => {
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("optimistically removes a survey and invalidates list queries on success", async () => {
let resolveFetch: ((value: Response) => void) | undefined;
const fetchPromise = new Promise<Response>((resolve) => {
resolveFetch = resolve;
});
vi.mocked(global.fetch).mockReturnValue(fetchPromise as Promise<Response>);
const queryClient = new QueryClient({
defaultOptions: {
mutations: { retry: false },
queries: { retry: false },
},
});
const queryKey = surveyKeys.list({
workspaceId: "env_1",
limit: 20,
filters: {
name: "",
status: [],
type: [],
sortBy: "relevance",
},
});
queryClient.setQueryData(queryKey, createQueryData());
const invalidateQueriesSpy = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(() => useDeleteSurvey({ queryKey }), {
wrapper: createWrapper(queryClient),
});
result.current.mutate({ surveyId: "survey_1" });
await waitFor(() =>
expect(queryClient.getQueryData<{ pages: TSurveyListPage[] }>(queryKey)?.pages[0]?.data).toEqual([])
);
expect(queryClient.getQueryData<{ pages: TSurveyListPage[] }>(queryKey)?.pages[0]?.meta.totalCount).toBe(
0
);
resolveFetch?.(
new Response(JSON.stringify({ data: { id: "survey_1" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: surveyKeys.lists() });
});
test("rolls the cache back when delete fails", async () => {
vi.mocked(global.fetch).mockResolvedValue(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
requestId: "req_1",
}),
{
status: 403,
headers: { "Content-Type": "application/problem+json" },
}
)
);
const queryClient = new QueryClient({
defaultOptions: {
mutations: { retry: false },
queries: { retry: false },
},
});
const queryKey = surveyKeys.list({
workspaceId: "env_1",
limit: 20,
filters: {
name: "",
status: [],
type: [],
sortBy: "relevance",
},
});
queryClient.setQueryData(queryKey, createQueryData());
const { result } = renderHook(() => useDeleteSurvey({ queryKey }), {
wrapper: createWrapper(queryClient),
});
await expect(
act(async () => {
await result.current.mutateAsync({ surveyId: "survey_1" });
})
).rejects.toThrow("You are not authorized to access this resource");
expect(queryClient.getQueryData<{ pages: TSurveyListPage[] }>(queryKey)?.pages[0]?.data).toHaveLength(1);
expect(queryClient.getQueryData<{ pages: TSurveyListPage[] }>(queryKey)?.pages[0]?.meta.totalCount).toBe(
1
);
});
});
@@ -0,0 +1,34 @@
"use client";
import { InfiniteData, useMutation, useQueryClient } from "@tanstack/react-query";
import { removeSurveyFromInfiniteData, surveyKeys } from "@/modules/survey/list/lib/query";
import { TSurveyListPage, deleteSurvey } from "@/modules/survey/list/lib/v3-surveys-client";
export const useDeleteSurvey = ({ queryKey }: { queryKey: ReturnType<typeof surveyKeys.list> }) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ surveyId }: { surveyId: string }) => deleteSurvey(surveyId),
onMutate: async ({ surveyId }) => {
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData<InfiniteData<TSurveyListPage>>(queryKey);
queryClient.setQueryData<InfiniteData<TSurveyListPage> | undefined>(queryKey, (currentData) =>
removeSurveyFromInfiniteData(currentData, surveyId)
);
return {
previousData,
};
},
onError: (_error, _variables, context) => {
if (context?.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: surveyKeys.lists() });
},
});
};
@@ -0,0 +1,238 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { type ReactNode, createElement } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { useSurveys } from "./use-surveys";
function createWrapper(queryClient: QueryClient) {
const Wrapper = ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
Wrapper.displayName = "UseSurveysTestWrapper";
return Wrapper;
}
describe("useSurveys", () => {
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
test("fetches the initial page and the next cursor page", async () => {
const fetchMock = vi.mocked(global.fetch);
fetchMock
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: [
{
id: "survey_1",
name: "Survey 1",
workspaceId: "env_1",
type: "link",
status: "draft",
createdAt: "2026-04-15T10:00:00.000Z",
updatedAt: "2026-04-15T10:00:00.000Z",
responseCount: 0,
creator: { name: "Alice" },
singleUse: null,
},
],
meta: {
limit: 20,
nextCursor: "cursor_1",
totalCount: 2,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: [
{
id: "survey_2",
name: "Survey 2",
workspaceId: "env_1",
type: "app",
status: "completed",
createdAt: "2026-04-15T11:00:00.000Z",
updatedAt: "2026-04-15T11:00:00.000Z",
responseCount: 2,
creator: { name: "Bob" },
singleUse: null,
},
],
meta: {
limit: 20,
nextCursor: null,
totalCount: 2,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const { result } = renderHook(
() =>
useSurveys({
workspaceId: "env_1",
limit: 20,
filters: {
name: "",
status: [],
type: [],
sortBy: "relevance",
},
}),
{ wrapper: createWrapper(queryClient) }
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.surveys).toHaveLength(1);
expect(result.current.totalCount).toBe(2);
expect(result.current.hasNextPage).toBe(true);
await act(async () => {
await result.current.fetchNextPage();
});
await waitFor(() => expect(result.current.surveys).toHaveLength(2));
expect(result.current.hasNextPage).toBe(false);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"/api/v3/surveys?workspaceId=env_1&limit=20&sortBy=relevance&cursor=cursor_1",
expect.objectContaining({
method: "GET",
cache: "no-store",
})
);
});
test("keeps the previous page data while refetching for new filters", async () => {
let resolveNextResponse: ((value: Response) => void) | undefined;
const nextResponsePromise = new Promise<Response>((resolve) => {
resolveNextResponse = resolve;
});
const fetchMock = vi.mocked(global.fetch);
fetchMock
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: [
{
id: "survey_1",
name: "Survey 1",
workspaceId: "env_1",
type: "link",
status: "draft",
createdAt: "2026-04-15T10:00:00.000Z",
updatedAt: "2026-04-15T10:00:00.000Z",
responseCount: 0,
creator: { name: "Alice" },
singleUse: null,
},
],
meta: {
limit: 20,
nextCursor: null,
totalCount: 1,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
)
.mockReturnValueOnce(nextResponsePromise as Promise<Response>);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const initialFilters: TSurveyOverviewFilters = {
name: "",
status: [],
type: [],
sortBy: "relevance",
};
const { result, rerender } = renderHook(
({ filters }) =>
useSurveys({
workspaceId: "env_1",
limit: 20,
filters,
}),
{
initialProps: { filters: initialFilters },
wrapper: createWrapper(queryClient),
}
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.surveys).toHaveLength(1);
rerender({
filters: {
...initialFilters,
name: "new query",
},
});
await waitFor(() => expect(result.current.isFetching).toBe(true));
expect(result.current.surveys).toHaveLength(1);
expect(result.current.surveys[0]?.name).toBe("Survey 1");
resolveNextResponse?.(
new Response(
JSON.stringify({
data: [
{
id: "survey_2",
name: "Survey 2",
workspaceId: "env_1",
type: "link",
status: "paused",
createdAt: "2026-04-15T11:00:00.000Z",
updatedAt: "2026-04-15T11:00:00.000Z",
responseCount: 4,
creator: { name: "Bob" },
singleUse: null,
},
],
meta: {
limit: 20,
nextCursor: null,
totalCount: 1,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);
await waitFor(() => expect(result.current.surveys[0]?.name).toBe("Survey 2"));
});
});
@@ -0,0 +1,50 @@
"use client";
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
import { flattenSurveyPages, surveyKeys } from "@/modules/survey/list/lib/query";
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { listSurveys } from "../lib/v3-surveys-client";
export const useSurveys = ({
workspaceId,
limit,
filters,
enabled = true,
}: {
workspaceId: string;
limit: number;
filters: TSurveyOverviewFilters;
enabled?: boolean;
}) => {
const queryKey = surveyKeys.list({
workspaceId,
limit,
filters,
});
const query = useInfiniteQuery({
queryKey,
initialPageParam: null as string | null,
enabled,
placeholderData: keepPreviousData,
queryFn: ({ pageParam, signal }) =>
listSurveys({
workspaceId,
limit,
cursor: pageParam,
filters,
signal,
}),
getNextPageParam: (lastPage) => lastPage.meta.nextCursor ?? undefined,
});
const surveys = flattenSurveyPages(query.data);
const totalCount = query.data?.pages[0]?.meta.totalCount ?? 0;
return {
...query,
queryKey,
surveys,
totalCount,
};
};
@@ -1,8 +1,7 @@
import { TSurveyFilters } from "@formbricks/types/surveys/types";
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
export const initialFilters: TSurveyFilters = {
export const initialFilters: TSurveyOverviewFilters = {
name: "",
createdBy: [],
status: [],
type: [],
sortBy: "relevance",
@@ -0,0 +1,66 @@
import type { InfiniteData } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import { flattenSurveyPages, removeSurveyFromInfiniteData } from "./query";
import { TSurveyListPage } from "./v3-surveys-client";
const surveyA = {
id: "survey_a",
name: "Survey A",
workspaceId: "env_1",
type: "link" as const,
status: "draft" as const,
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "Alice" },
singleUse: null,
};
const surveyB = {
...surveyA,
id: "survey_b",
name: "Survey B",
};
const baseData: InfiniteData<TSurveyListPage> = {
pages: [
{
data: [surveyA],
meta: {
limit: 20,
nextCursor: "cursor-1",
totalCount: 2,
},
},
{
data: [surveyB],
meta: {
limit: 20,
nextCursor: null,
totalCount: 2,
},
},
],
pageParams: [null, "cursor-1"],
};
describe("flattenSurveyPages", () => {
test("flattens every fetched page", () => {
expect(flattenSurveyPages(baseData)).toEqual([surveyA, surveyB]);
});
});
describe("removeSurveyFromInfiniteData", () => {
test("removes the survey from cached pages and decrements each page total", () => {
const nextData = removeSurveyFromInfiniteData(baseData, "survey_a");
expect(nextData?.pages[0]?.data).toEqual([]);
expect(nextData?.pages[1]?.data).toEqual([surveyB]);
expect(nextData?.pages[0]?.meta.totalCount).toBe(1);
expect(nextData?.pages[1]?.meta.totalCount).toBe(1);
});
test("returns the original cache when the survey is not present", () => {
expect(removeSurveyFromInfiniteData(baseData, "missing_survey")).toBe(baseData);
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { InfiniteData } from "@tanstack/react-query";
import { TSurveyListItem, TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
import { TSurveyListPage } from "./v3-surveys-client";
type TSurveyListKeyInput = {
workspaceId: string;
limit: number;
filters: TSurveyOverviewFilters;
};
export const surveyKeys = {
all: ["surveys"] as const,
lists: () => [...surveyKeys.all, "list"] as const,
list: (input: TSurveyListKeyInput) => [...surveyKeys.lists(), input] as const,
};
export function flattenSurveyPages(data?: InfiniteData<TSurveyListPage>): TSurveyListItem[] {
return data?.pages.flatMap((page) => page.data) ?? [];
}
export function removeSurveyFromInfiniteData(
data: InfiniteData<TSurveyListPage> | undefined,
surveyId: string
): InfiniteData<TSurveyListPage> | undefined {
if (!data) {
return data;
}
let surveyWasRemoved = false;
const pages = data.pages.map((page) => {
const nextData = page.data.filter((survey) => survey.id !== surveyId);
if (nextData.length !== page.data.length) {
surveyWasRemoved = true;
}
return {
...page,
data: nextData,
};
});
if (!surveyWasRemoved) {
return data;
}
return {
...data,
pages: pages.map((page) => ({
...page,
meta: {
...page.meta,
totalCount: Math.max(0, page.meta.totalCount - 1),
},
})),
};
}
@@ -17,7 +17,6 @@ import { TProjectWithLanguages, TSurvey } from "../types/surveys";
// Import the module to be tested
import {
copySurveyToOtherEnvironment,
deleteSurvey,
getSurvey,
getSurveyCount,
getSurveys,
@@ -420,57 +419,6 @@ describe("getSurveysSortedByRelevance", () => {
});
});
describe("deleteSurvey", () => {
beforeEach(() => {
resetMocks();
});
const mockDeletedSurveyData = {
id: surveyId,
environmentId,
segment: null,
type: "web" as any,
triggers: [{ actionClass: { id: "action_1" } }],
};
test("should delete a survey and revalidate caches (no private segment)", async () => {
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyData as any);
const result = await deleteSurvey(surveyId);
expect(result).toBe(true);
expect(prisma.survey.delete).toHaveBeenCalledWith({
where: { id: surveyId },
select: expect.objectContaining({ id: true, environmentId: true, segment: expect.anything() }),
});
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should revalidate segment cache for non-private segment if segment exists", async () => {
const surveyWithPublicSegment = {
...mockDeletedSurveyData,
segment: { id: "segment_public_1", isPrivate: false },
};
vi.mocked(prisma.survey.delete).mockResolvedValue(surveyWithPublicSegment as any);
await deleteSurvey(surveyId);
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should throw DatabaseError on Prisma error", async () => {
const prismaError = makePrismaKnownError();
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error deleting survey");
});
test("should rethrow unknown error", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.survey.delete).mockRejectedValue(unknownError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(unknownError);
});
});
const mockExistingSurveyDetails = {
name: "Original Survey",
type: "web" as any,
@@ -145,53 +145,6 @@ export const getSurvey = reactCache(async (surveyId: string): Promise<TSurvey |
return mapSurveyRowToSurvey(surveyPrisma);
});
export const deleteSurvey = async (surveyId: string): Promise<boolean> => {
try {
const deletedSurvey = await prisma.survey.delete({
where: {
id: surveyId,
},
select: {
id: true,
environmentId: true,
segment: {
select: {
id: true,
isPrivate: true,
},
},
type: true,
triggers: {
select: {
actionClass: {
select: {
id: true,
},
},
},
},
},
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
await prisma.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
}
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error deleting survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
const getExistingSurvey = async (surveyId: string) => {
return await prisma.survey.findUnique({
where: {
+90 -60
View File
@@ -1,68 +1,98 @@
import { describe, expect, test } from "vitest";
import type { TSurveyFilters } from "@formbricks/types/surveys/types";
import { getFormattedFilters } from "./utils";
import { hasActiveSurveyFilters, normalizeSurveyFilters, parseStoredSurveyFilters } from "./utils";
describe("getFormattedFilters", () => {
test("returns empty object when no filters provided", () => {
const result = getFormattedFilters({} as TSurveyFilters, "user1");
expect(result).toEqual({});
describe("normalizeSurveyFilters", () => {
test("returns the normalized default filters when input is empty", () => {
expect(normalizeSurveyFilters(undefined)).toEqual({
name: "",
status: [],
type: [],
sortBy: "relevance",
});
});
test("includes name filter", () => {
const result = getFormattedFilters({ name: "surveyName" } as TSurveyFilters, "user1");
expect(result).toEqual({ name: "surveyName" });
});
test("includes status filter when array is non-empty", () => {
const result = getFormattedFilters({ status: ["active", "inactive"] } as any, "user1");
expect(result).toEqual({ status: ["active", "inactive"] });
});
test("ignores status filter when empty array", () => {
const result = getFormattedFilters({ status: [] } as any, "user1");
expect(result).toEqual({});
});
test("includes type filter when array is non-empty", () => {
const result = getFormattedFilters({ type: ["typeA"] } as any, "user1");
expect(result).toEqual({ type: ["typeA"] });
});
test("ignores type filter when empty array", () => {
const result = getFormattedFilters({ type: [] } as any, "user1");
expect(result).toEqual({});
});
test("includes createdBy filter when array is non-empty", () => {
const result = getFormattedFilters({ createdBy: ["ownerA", "ownerB"] } as any, "user1");
expect(result).toEqual({ createdBy: { userId: "user1", value: ["ownerA", "ownerB"] } });
});
test("ignores createdBy filter when empty array", () => {
const result = getFormattedFilters({ createdBy: [] } as any, "user1");
expect(result).toEqual({});
});
test("includes sortBy filter", () => {
const result = getFormattedFilters({ sortBy: "date" } as any, "user1");
expect(result).toEqual({ sortBy: "date" });
});
test("combines multiple filters", () => {
const input: TSurveyFilters = {
name: "nameVal",
status: ["draft"],
type: ["link", "app"],
createdBy: ["you"],
sortBy: "name",
};
const result = getFormattedFilters(input, "userX");
expect(result).toEqual({
name: "nameVal",
status: ["draft"],
type: ["link", "app"],
createdBy: { userId: "userX", value: ["you"] },
test("trims names, removes unsupported fields, and sorts filter arrays", () => {
expect(
normalizeSurveyFilters({
name: " Customer feedback ",
createdBy: ["you"],
status: ["paused", "draft", "paused"],
type: ["link", "app", "link"],
sortBy: "name",
} as any)
).toEqual({
name: "Customer feedback",
status: ["draft", "paused"],
type: ["app", "link"],
sortBy: "name",
});
});
test("drops type filters when the project channel is link-only", () => {
expect(
normalizeSurveyFilters(
{
name: "",
status: [],
type: ["app", "link"],
sortBy: "updatedAt",
},
"link"
)
).toEqual({
name: "",
status: [],
type: [],
sortBy: "updatedAt",
});
});
});
describe("parseStoredSurveyFilters", () => {
test("returns null for invalid JSON", () => {
expect(parseStoredSurveyFilters("{")).toBeNull();
});
test("sanitizes legacy stored filters", () => {
expect(
parseStoredSurveyFilters(
JSON.stringify({
name: " NPS ",
createdBy: ["you"],
status: ["completed", "draft"],
type: ["link"],
sortBy: "createdAt",
})
)
).toEqual({
name: "NPS",
status: ["completed", "draft"],
type: ["link"],
sortBy: "createdAt",
});
});
});
describe("hasActiveSurveyFilters", () => {
test("ignores sort-only changes", () => {
expect(
hasActiveSurveyFilters({
name: "",
status: [],
type: [],
sortBy: "createdAt",
})
).toBe(false);
});
test("detects active filters", () => {
expect(
hasActiveSurveyFilters({
name: "CSAT",
status: [],
type: [],
sortBy: "relevance",
})
).toBe(true);
});
});
+67 -20
View File
@@ -1,30 +1,77 @@
import { TSurveyFilterCriteria, TSurveyFilters } from "@formbricks/types/surveys/types";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { initialFilters } from "@/modules/survey/list/lib/constants";
import {
TSurveyOverviewFilters,
TSurveyOverviewSort,
TSurveyOverviewType,
} from "@/modules/survey/list/types/survey-overview";
export const getFormattedFilters = (surveyFilters: TSurveyFilters, userId: string): TSurveyFilterCriteria => {
const filters: TSurveyFilterCriteria = {};
const allowedStatus = new Set(["draft", "inProgress", "paused", "completed"] as const);
const allowedType = new Set(["app", "link"] as const);
const allowedSort = new Set(["createdAt", "updatedAt", "name", "relevance"] as const);
const compareNormalizedFilterValues = (left: string, right: string) => left.localeCompare(right);
if (surveyFilters.name) {
filters.name = surveyFilters.name;
function getNormalizedStatus(value: unknown): TSurveyOverviewFilters["status"] {
if (!Array.isArray(value)) {
return [];
}
if (surveyFilters.status && surveyFilters.status.length) {
filters.status = surveyFilters.status;
return [
...new Set(
value.filter((status): status is TSurveyOverviewFilters["status"][number] =>
allowedStatus.has(status as never)
)
),
].sort(compareNormalizedFilterValues);
}
function getNormalizedType(
value: unknown,
currentProjectChannel?: TProjectConfigChannel
): TSurveyOverviewType[] {
if (currentProjectChannel === "link" || !Array.isArray(value)) {
return [];
}
if (surveyFilters.type && surveyFilters.type.length) {
filters.type = surveyFilters.type;
return [
...new Set(value.filter((type): type is TSurveyOverviewType => allowedType.has(type as never))),
].sort(compareNormalizedFilterValues);
}
function getNormalizedSort(value: unknown): TSurveyOverviewSort {
return allowedSort.has(value as never) ? (value as TSurveyOverviewSort) : initialFilters.sortBy;
}
export function normalizeSurveyFilters(
filters: Partial<TSurveyOverviewFilters> | null | undefined,
currentProjectChannel?: TProjectConfigChannel
): TSurveyOverviewFilters {
return {
name: typeof filters?.name === "string" ? filters.name.trim() : initialFilters.name,
status: getNormalizedStatus(filters?.status),
type: getNormalizedType(filters?.type, currentProjectChannel),
sortBy: getNormalizedSort(filters?.sortBy),
};
}
export function parseStoredSurveyFilters(
storedValue: string | null,
currentProjectChannel?: TProjectConfigChannel
): TSurveyOverviewFilters | null {
if (!storedValue) {
return null;
}
if (surveyFilters.createdBy && surveyFilters.createdBy.length) {
filters.createdBy = {
userId: userId,
value: surveyFilters.createdBy,
};
try {
return normalizeSurveyFilters(
JSON.parse(storedValue) as Partial<TSurveyOverviewFilters>,
currentProjectChannel
);
} catch {
return null;
}
}
if (surveyFilters.sortBy) {
filters.sortBy = surveyFilters.sortBy;
}
return filters;
};
export function hasActiveSurveyFilters(filters: TSurveyOverviewFilters): boolean {
return Boolean(filters.name) || filters.status.length > 0 || filters.type.length > 0;
}
@@ -0,0 +1,22 @@
import { describe, expect, test } from "vitest";
import { buildSurveyListSearchParams } from "./v3-surveys-client";
describe("buildSurveyListSearchParams", () => {
test("emits only supported v3 params using normalized filter values", () => {
const searchParams = buildSurveyListSearchParams({
workspaceId: "env_1",
limit: 20,
cursor: "cursor_1",
filters: {
name: " Product feedback ",
status: ["paused", "draft"],
type: ["link", "app"],
sortBy: "relevance",
},
});
expect(searchParams.toString()).toBe(
"workspaceId=env_1&limit=20&sortBy=relevance&cursor=cursor_1&filter%5Bname%5D%5Bcontains%5D=Product+feedback&filter%5Bstatus%5D%5Bin%5D=draft&filter%5Bstatus%5D%5Bin%5D=paused&filter%5Btype%5D%5Bin%5D=app&filter%5Btype%5D%5Bin%5D=link"
);
});
});
@@ -0,0 +1,121 @@
import { parseV3ApiError } from "@/modules/api/lib/v3-client";
import { normalizeSurveyFilters } from "@/modules/survey/list/lib/utils";
import { TSurveyListItem, TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
type TV3SurveyListItemResponse = Omit<TSurveyListItem, "createdAt" | "updatedAt"> & {
createdAt: string;
updatedAt: string;
};
type TV3SurveyListResponse = {
data: TV3SurveyListItemResponse[];
meta: TSurveyListPage["meta"];
};
type TV3DeleteSurveyResponse = {
data: {
id: string;
};
};
export type TSurveyListPage = {
data: TSurveyListItem[];
meta: {
limit: number;
nextCursor: string | null;
totalCount: number;
};
};
function mapSurveyListItem(survey: TV3SurveyListItemResponse): TSurveyListItem {
return {
...survey,
createdAt: new Date(survey.createdAt),
updatedAt: new Date(survey.updatedAt),
};
}
export function buildSurveyListSearchParams({
workspaceId,
limit,
cursor,
filters,
}: {
workspaceId: string;
limit: number;
cursor?: string | null;
filters: TSurveyOverviewFilters;
}): URLSearchParams {
const normalizedFilters = normalizeSurveyFilters(filters);
const searchParams = new URLSearchParams();
searchParams.set("workspaceId", workspaceId);
searchParams.set("limit", String(limit));
searchParams.set("sortBy", normalizedFilters.sortBy);
if (cursor) {
searchParams.set("cursor", cursor);
}
if (normalizedFilters.name) {
searchParams.set("filter[name][contains]", normalizedFilters.name);
}
normalizedFilters.status.forEach((status) => {
searchParams.append("filter[status][in]", status);
});
normalizedFilters.type.forEach((type) => {
searchParams.append("filter[type][in]", type);
});
return searchParams;
}
export async function listSurveys({
workspaceId,
limit,
cursor,
filters,
signal,
}: {
workspaceId: string;
limit: number;
cursor?: string | null;
filters: TSurveyOverviewFilters;
signal?: AbortSignal;
}): Promise<TSurveyListPage> {
const response = await fetch(
`/api/v3/surveys?${buildSurveyListSearchParams({ workspaceId, limit, cursor, filters }).toString()}`,
{
method: "GET",
cache: "no-store",
signal,
}
);
if (!response.ok) {
throw await parseV3ApiError(response);
}
const body = (await response.json()) as TV3SurveyListResponse;
return {
data: body.data.map(mapSurveyListItem),
meta: body.meta,
};
}
export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
const response = await fetch(`/api/v3/surveys/${surveyId}`, {
method: "DELETE",
cache: "no-store",
});
if (!response.ok) {
throw await parseV3ApiError(response);
}
const body = (await response.json()) as TV3DeleteSurveyResponse;
return body.data;
}
+12 -60
View File
@@ -1,6 +1,4 @@
import { PlusIcon } from "lucide-react";
import { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD, SURVEYS_PER_PAGE } from "@/lib/constants";
@@ -11,11 +9,6 @@ import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
import { SurveysList } from "@/modules/survey/list/components/survey-list";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { TemplateContainerWithPreview } from "@/modules/survey/templates/components/template-container";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
export const metadata: Metadata = {
title: "Your Surveys",
@@ -44,65 +37,24 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
}
const surveyCount = await getSurveyCount(params.environmentId);
const currentProjectChannel = project.config.channel ?? null;
const locale = (await getUserLocale(session.user.id)) ?? DEFAULT_LOCALE;
const createSurveyButton = (
<Button size="sm" asChild>
<Link href={`/environments/${environment.id}/surveys/templates`}>
{t("environments.surveys.new_survey")}
<PlusIcon />
</Link>
</Button>
);
const projectWithRequiredProps = {
...project,
brandColor: project.styling?.brandColor?.light ?? null,
highlightBorderColor: null,
};
if (surveyCount === 0)
return (
<TemplateContainerWithPreview
userId={session.user.id}
environment={environment}
project={projectWithRequiredProps}
isTemplatePage={false}
publicDomain={publicDomain}
/>
);
let content;
if (surveyCount > 0) {
content = (
<>
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : createSurveyButton} />
<SurveysList
environmentId={environment.id}
isReadOnly={isReadOnly}
publicDomain={publicDomain}
userId={session.user.id}
surveysPerPage={SURVEYS_PER_PAGE}
currentProjectChannel={currentProjectChannel}
locale={locale}
/>
</>
);
} else if (isReadOnly) {
content = (
<>
<h1 className="px-6 text-3xl font-extrabold text-slate-700">
{t("environments.surveys.no_surveys_created_yet")}
</h1>
<h2 className="px-6 text-lg font-medium text-slate-500">
{t("environments.surveys.read_only_user_not_allowed_to_create_survey_warning")}
</h2>
</>
);
}
return <PageContentWrapper>{content}</PageContentWrapper>;
return (
<SurveysList
environment={environment}
project={projectWithRequiredProps}
isReadOnly={isReadOnly}
publicDomain={publicDomain}
userId={session.user.id}
surveysPerPage={SURVEYS_PER_PAGE}
currentProjectChannel={currentProjectChannel}
locale={locale}
/>
);
};
@@ -0,0 +1,39 @@
import { z } from "zod";
import { ZSurveyStatus } from "@formbricks/types/surveys/types";
export const ZSurveyOverviewType = z.enum(["link", "app"]);
export const ZSurveyOverviewSort = z.enum(["createdAt", "updatedAt", "name", "relevance"]);
export const ZSurveyOverviewFilters = z.object({
name: z.string(),
status: z.array(ZSurveyStatus),
type: z.array(ZSurveyOverviewType),
sortBy: ZSurveyOverviewSort,
});
export const ZSurveyListItem = z.object({
id: z.string(),
name: z.string(),
workspaceId: z.string(),
type: z.enum(["link", "app", "website", "web"]),
status: ZSurveyStatus,
createdAt: z.date(),
updatedAt: z.date(),
responseCount: z.number(),
creator: z
.object({
name: z.string(),
})
.nullable(),
singleUse: z
.object({
enabled: z.boolean(),
isEncrypted: z.boolean(),
})
.nullable(),
});
export type TSurveyOverviewType = z.infer<typeof ZSurveyOverviewType>;
export type TSurveyOverviewStatus = z.infer<typeof ZSurveyStatus>;
export type TSurveyOverviewSort = z.infer<typeof ZSurveyOverviewSort>;
export type TSurveyOverviewFilters = z.infer<typeof ZSurveyOverviewFilters>;
export type TSurveyListItem = z.infer<typeof ZSurveyListItem>;
@@ -26,17 +26,6 @@ export const ZSurvey = z.object({
export type TSurvey = z.infer<typeof ZSurvey>;
export const ZSurveyCopyFormValidation = z.object({
projects: z.array(
z.object({
project: z.string(),
environments: z.array(z.string()),
})
),
});
export type TSurveyCopyFormData = z.infer<typeof ZSurveyCopyFormValidation>;
export interface TProjectWithLanguages extends Pick<Project, "id"> {
languages: Pick<Language, "code" | "alias">[];
}
@@ -67,6 +67,15 @@ 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 || !file.type) {
if (!file) {
continue;
}
@@ -79,14 +79,14 @@ export const TagsCombobox = ({
return 0;
}}>
<div className="p-1">
<div className="px-1 pt-1">
<CommandInput
placeholder={
tagsToSearch?.length === 0
? t("environments.workspace.tags.add_tag")
: t("environments.workspace.tags.search_tags")
}
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"
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"
value={searchValue}
onValueChange={(search) => setSearchValue(search)}
onKeyDown={(e) => {
@@ -103,7 +103,7 @@ export const TagsCombobox = ({
/>
</div>
<CommandList className="border-0">
<CommandGroup>
<CommandGroup className="p-0">
{tagsToSearch?.map((tag) => {
return (
<CommandItem
+4 -3
View File
@@ -74,6 +74,7 @@
"@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",
@@ -94,14 +95,14 @@
"jiti": "2.6.1",
"jsonwebtoken": "9.0.3",
"lexical": "0.41.0",
"lodash": "4.17.23",
"lodash": "4.18.1",
"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.2",
"nodemailer": "8.0.4",
"otplib": "12.0.1",
"papaparse": "5.5.3",
"posthog-js": "1.360.0",
@@ -153,7 +154,7 @@
"dotenv": "17.3.1",
"postcss": "8.5.8",
"resize-observer-polyfill": "1.5.1",
"vite": "7.3.1",
"vite": "7.3.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.0.18",
"vitest-mock-extended": "3.1.0"
+393
View File
@@ -0,0 +1,393 @@
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);
});
});
+91 -6
View File
@@ -1,12 +1,12 @@
# V3 API — GET Surveys (hand-maintained; not generated by generate-api-specs).
# Implementation: apps/web/app/api/v3/surveys/route.ts
# 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
# 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** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace environment).
**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).
**Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
@@ -28,8 +28,11 @@ 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 (single survey, CRUD), frontend cutover from `getSurveysAction`, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
Additional v3 survey endpoints, 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
@@ -173,6 +176,72 @@ 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:
@@ -193,12 +262,13 @@ components:
SurveyListItem:
type: object
description: |
Shape from `getSurveys` (`surveySelect` + `responseCount`). Serialized dates are ISO 8601 strings.
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`.
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 }
environmentId: { type: string }
workspaceId: { type: string }
type: { type: string, enum: [link, app, website, web] }
status:
type: string
@@ -207,6 +277,21 @@ 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.0",
"vite": "8.0.5",
"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.1",
"vite": "7.3.2",
"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.1",
"vite": "7.3.2",
"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.1",
"vite": "7.3.2",
"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.1",
"vite": "7.3.2",
"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.1",
"vite": "7.3.2",
"@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.1",
"vite": "7.3.2",
"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.1",
"vite": "7.3.2",
"vite-plugin-dts": "4.5.4",
"vite-tsconfig-paths": "6.1.1",
"@vitest/coverage-v8": "4.0.18",
@@ -78,6 +78,58 @@ 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,
@@ -128,6 +180,19 @@ 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;
@@ -162,7 +227,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 === selectedValue);
const selectedOption = options.find((opt) => opt.id === effectiveSelectedValue);
const displayText = isOtherSelected
? otherValue || otherOptionLabel
: (selectedOption?.label ?? placeholder);
@@ -185,11 +250,7 @@ function SingleSelect({
{variant === "dropdown" ? (
<>
<ElementError errorMessage={errorMessage} dir={dir} />
<DropdownMenu
onOpenChange={(open) => {
if (open) handleDropdownOpen();
else handleDropdownClose();
}}>
<DropdownMenu onOpenChange={handleDropdownOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
@@ -217,7 +278,12 @@ function SingleSelect({
/>
) : null}
<div className="max-h-[260px] overflow-y-auto">
<DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}>
<DropdownMenuRadioGroup
value={effectiveSelectedValue}
onValueChange={(newValue) => {
setPendingDropdownValue(newValue);
setHasPendingDropdownChange(newValue !== selectedValue);
}}>
{filteredRegularOptions.map((option) => {
const optionId = `${inputId}-${option.id}`;
@@ -20,12 +20,14 @@ 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,7 +58,9 @@ export function useDropdownSearch<T extends { id: string; label: string }>({
const focusSearchAndLockSide = (): void => {
searchInputRef.current?.focus();
const side = contentRef.current?.dataset.side;
const dataset = contentRef.current?.dataset;
if (!dataset) return;
const side = dataset.side;
if (side === "top" || side === "bottom") setLockedSide(side);
};
+5 -5
View File
@@ -9,7 +9,7 @@
"finish": "Befejezés",
"language_switch": "Nyelvválasztó",
"next": "Következő",
"no_results_found": "Nincs találat",
"no_results_found": "Nincsenek találatok",
"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.",
@@ -20,10 +20,10 @@
"question_video": "Kérdés videója",
"required": "Kötelező",
"respondents_will_not_see_this_card": "A válaszadók nem fogják látni ezt a kártyát",
"response_saved_offline": "A válaszod még nem lett elküldve. Automatikusan el fogjuk küldeni, amint újra online leszel.",
"response_saved_offline": "A válasza még nem lett elküldve. Automatikusan elküldésre kerül, amint újra elérhető lesz.",
"retry": "Újrapróbálkozás",
"retrying": "Újrapróbálkozás…",
"search": "Keresés...",
"search": "Keresés",
"select_option": "Lehetőség kiválasztása",
"select_options": "Lehetőségek kiválasztása",
"sending_responses": "Válaszok küldése…",
@@ -47,9 +47,9 @@
"file_size_exceeded_alert": "A fájlnak kisebbnek kell lennie mint {maxSizeInMB} MB",
"no_valid_file_types_selected": "Nincs érvényes fájltípus kiválasztva. Válasszon egy érvényes fájltípust.",
"only_one_file_can_be_uploaded_at_a_time": "Egyszerre csak egy fájl tölthető fel.",
"placeholder_text": "Kattints vagy húzd ide a fájlokat a feltöltéshez",
"placeholder_text": "Kattintson vagy húzza ide a fájlok feltöltéséhez",
"upload_failed": "A feltöltés nem sikerült! Próbálja meg újra.",
"uploading": "Feltöltés...",
"uploading": "Feltöltés",
"you_can_only_upload_a_maximum_of_files": "Legfeljebb csak {FILE_LIMIT} fájlt tölthet fel."
},
"invalid_device_error": {
+1 -1
View File
@@ -69,7 +69,7 @@
"rollup-plugin-visualizer": "7.0.1",
"tailwindcss": "4.2.1",
"terser": "5.46.0",
"vite": "7.3.1",
"vite": "7.3.2",
"vite-plugin-dts": "4.5.4",
"vite-tsconfig-paths": "6.1.1"
}
@@ -20,6 +20,8 @@ import { getLocalizedValue } from "@/lib/i18n";
import { cn } from "@/lib/utils";
import { getFirstErrorMessage, validateBlockResponses } from "@/lib/validation/evaluator";
const AUTO_PROGRESS_SUBMIT_DELAY_MS = 350;
interface BlockConditionalProps {
block: TSurveyBlock;
value: TResponseData;
@@ -82,7 +84,8 @@ export function BlockConditional({
const autoProgressElement = getAutoProgressElement(block.elements, isAutoProgressingEnabled);
const shouldHideSubmitButton = shouldHideSubmitButtonForAutoProgress(
block.elements,
isAutoProgressingEnabled
isAutoProgressingEnabled,
value
);
// Handle change for an individual element
@@ -128,7 +131,7 @@ export function BlockConditional({
} finally {
autoProgressingInFlightRef.current = false;
}
}, 0);
}, AUTO_PROGRESS_SUBMIT_DELAY_MS);
}
};
@@ -0,0 +1,382 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { Survey } from "./survey";
const apiClientMocks = vi.hoisted(() => ({
createDisplay: vi.fn(),
createResponse: vi.fn(),
updateResponse: vi.fn(),
getResponseIdByDisplayId: vi.fn(),
}));
const offlineStorageMocks = vi.hoisted(() => ({
addPendingResponse: vi.fn(),
countPendingResponses: vi.fn(),
getPendingResponses: vi.fn(),
removePendingResponse: vi.fn(),
clearSurveyProgress: vi.fn(),
getSurveyProgress: vi.fn(),
patchSurveyProgressSnapshot: vi.fn(),
saveSurveyProgress: vi.fn(),
}));
vi.mock("@/lib/api-client", () => ({
ApiClient: vi.fn(function ApiClient() {
return apiClientMocks;
}),
}));
vi.mock("@/lib/offline-storage", () => ({
addPendingResponse: offlineStorageMocks.addPendingResponse,
countPendingResponses: offlineStorageMocks.countPendingResponses,
getPendingResponses: offlineStorageMocks.getPendingResponses,
removePendingResponse: offlineStorageMocks.removePendingResponse,
clearSurveyProgress: offlineStorageMocks.clearSurveyProgress,
getSurveyProgress: offlineStorageMocks.getSurveyProgress,
patchSurveyProgressSnapshot: offlineStorageMocks.patchSurveyProgressSnapshot,
saveSurveyProgress: offlineStorageMocks.saveSurveyProgress,
}));
vi.mock("@/lib/use-online-status", () => ({
useOnlineStatus: () => true,
}));
vi.mock("@/lib/recall", () => ({
parseRecallInformation: (element: unknown) => element,
}));
vi.mock("@/components/general/block-conditional", () => ({
BlockConditional: ({ block, onSubmit }: any) => (
<button
data-testid={`submit-${block.id}`}
onClick={() =>
onSubmit(
{ [block.elements[0].id]: `${block.id}-answer` },
{
[block.elements[0].id]: 123,
}
)
}>
Submit {block.id}
</button>
),
}));
vi.mock("@/components/wrappers/stacked-cards-container", () => ({
StackedCardsContainer: ({ currentBlockId, getCardContent, survey }: any) => {
const blockIndex =
currentBlockId === "start" ? -1 : survey.blocks.findIndex((block: any) => block.id === currentBlockId);
return <div data-testid="survey-root">{getCardContent(blockIndex, 0)}</div>;
},
}));
vi.mock("@/components/wrappers/auto-close-wrapper", () => ({
AutoCloseWrapper: ({ children }: any) => <>{children}</>,
}));
vi.mock("@/components/general/ending-card", () => ({
EndingCard: () => <div data-testid="ending-card">Ending</div>,
}));
vi.mock("@/components/general/welcome-card", () => ({
WelcomeCard: () => <div data-testid="welcome-card">Welcome</div>,
}));
vi.mock("@/components/general/error-component", () => ({
ErrorComponent: () => <div>Error</div>,
}));
vi.mock("@/components/general/response-error-component", () => ({
ResponseErrorComponent: () => <div>Response Error</div>,
}));
vi.mock("@/components/general/formbricks-branding", () => ({
FormbricksBranding: () => null,
}));
vi.mock("@/components/general/language-switch", () => ({
LanguageSwitch: () => null,
}));
vi.mock("@/components/general/progress-bar", () => ({
ProgressBar: () => null,
}));
vi.mock("@/components/general/recaptcha-branding", () => ({
RecaptchaBranding: () => null,
}));
vi.mock("@/components/general/survey-close-button", () => ({
SurveyCloseButton: () => null,
}));
const defaultLanguage = {
default: true,
enabled: true,
language: {
id: "lang123456789012345678901",
code: "en",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project12345678901234567",
},
};
const baseSurvey: TJsEnvironmentStateSurvey = {
id: "survey12345678901234567890",
name: "Offline Resume Survey",
type: "link",
status: "inProgress",
questions: [],
blocks: [
{
id: "block-1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
required: false,
},
],
logic: [],
},
{
id: "block-2",
elements: [
{
id: "q2",
type: TSurveyElementTypeEnum.OpenText,
required: false,
},
],
logic: [],
},
],
endings: [{ id: "ending-1" }],
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
variables: [],
styling: { overwriteThemeStyling: false },
recontactDays: null,
displayLimit: null,
displayPercentage: null,
languages: [defaultLanguage],
segment: null,
hiddenFields: { enabled: false, fieldIds: [] },
projectOverwrites: null,
triggers: [],
displayOption: "displayOnce",
showLanguageSwitch: false,
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
recaptcha: {
enabled: false,
},
} as unknown as TJsEnvironmentStateSurvey;
const makeProgress = (overrides: Record<string, unknown> = {}) => ({
surveyId: baseSurvey.id,
blockId: "block-2",
responseData: { q1: "saved-answer" },
ttc: { q1: 111 },
currentVariables: { savedVar: "saved" },
history: ["block-1"],
selectedLanguage: "en",
surveyStateSnapshot: {
responseId: null,
displayId: "display1234567890123456789",
surveyId: baseSurvey.id,
singleUseId: null,
userId: null,
contactId: null,
responseAcc: {
finished: false,
data: { q1: "saved-answer" },
ttc: { q1: 111 },
variables: { savedVar: "saved" },
},
},
updatedAt: Date.now(),
...overrides,
});
const renderSurvey = () =>
render(
<Survey
appUrl="http://localhost:3000"
environmentId="env1234567890123456789012"
survey={baseSurvey}
styling={{} as any}
isBrandingEnabled={false}
languageCode="en"
offlineSupport
isSpamProtectionEnabled={false}
/>
);
describe("Survey offline restore", () => {
beforeEach(() => {
vi.clearAllMocks();
offlineStorageMocks.addPendingResponse.mockResolvedValue(1);
offlineStorageMocks.countPendingResponses.mockResolvedValue(0);
offlineStorageMocks.getPendingResponses.mockResolvedValue([]);
offlineStorageMocks.removePendingResponse.mockResolvedValue(undefined);
offlineStorageMocks.clearSurveyProgress.mockResolvedValue(undefined);
offlineStorageMocks.patchSurveyProgressSnapshot.mockResolvedValue(undefined);
offlineStorageMocks.saveSurveyProgress.mockResolvedValue(undefined);
apiClientMocks.createDisplay.mockResolvedValue({ ok: true, data: { id: "display-created" } });
apiClientMocks.createResponse.mockResolvedValue({ ok: true, data: { id: "response-created" } });
apiClientMocks.updateResponse.mockResolvedValue({ ok: true, data: { quotaFull: false } });
apiClientMocks.getResponseIdByDisplayId.mockResolvedValue({ ok: true, data: { responseId: null } });
window.parent.postMessage = vi.fn();
});
afterEach(() => {
cleanup();
});
test("recovers responseId from displayId and continues on the update path", async () => {
offlineStorageMocks.getSurveyProgress.mockResolvedValue(makeProgress());
apiClientMocks.getResponseIdByDisplayId.mockResolvedValue({
ok: true,
data: { responseId: "response-recovered" },
});
renderSurvey();
await waitFor(() => {
expect(apiClientMocks.getResponseIdByDisplayId).toHaveBeenCalledWith("display1234567890123456789");
});
fireEvent.click(await screen.findByTestId("submit-block-2"));
await waitFor(() => {
expect(apiClientMocks.updateResponse).toHaveBeenCalledWith(
expect.objectContaining({
responseId: "response-recovered",
data: { q2: "block-2-answer" },
})
);
});
expect(apiClientMocks.createResponse).not.toHaveBeenCalled();
expect(apiClientMocks.createDisplay).not.toHaveBeenCalled();
expect(offlineStorageMocks.patchSurveyProgressSnapshot).toHaveBeenCalledWith(baseSurvey.id, {
responseId: "response-recovered",
});
});
test("bootstraps create from restored progress when display lookup returns no response", async () => {
offlineStorageMocks.getSurveyProgress.mockResolvedValue(makeProgress());
renderSurvey();
fireEvent.click(await screen.findByTestId("submit-block-2"));
await waitFor(() => {
expect(apiClientMocks.createResponse).toHaveBeenCalledWith(
expect.objectContaining({
displayId: "display1234567890123456789",
finished: true,
data: { q1: "saved-answer", q2: "block-2-answer" },
ttc: { q1: 111, q2: 123 },
variables: { savedVar: "saved" },
})
);
});
expect(apiClientMocks.updateResponse).not.toHaveBeenCalled();
expect(apiClientMocks.createDisplay).not.toHaveBeenCalled();
expect(offlineStorageMocks.patchSurveyProgressSnapshot).toHaveBeenCalledWith(baseSurvey.id, {
responseId: "response-created",
});
});
test("creates a new display and bootstraps from restored progress when no ids are saved", async () => {
offlineStorageMocks.getSurveyProgress.mockResolvedValue(
makeProgress({
surveyStateSnapshot: {
responseId: null,
displayId: null,
surveyId: baseSurvey.id,
singleUseId: null,
userId: null,
contactId: null,
responseAcc: {
finished: false,
data: { q1: "saved-answer" },
ttc: { q1: 111 },
variables: { savedVar: "saved" },
},
},
})
);
renderSurvey();
await waitFor(() => {
expect(apiClientMocks.createDisplay).toHaveBeenCalled();
});
fireEvent.click(await screen.findByTestId("submit-block-2"));
await waitFor(() => {
expect(apiClientMocks.createResponse).toHaveBeenCalledWith(
expect.objectContaining({
displayId: "display-created",
data: { q1: "saved-answer", q2: "block-2-answer" },
})
);
});
expect(offlineStorageMocks.patchSurveyProgressSnapshot).toHaveBeenCalledWith(baseSurvey.id, {
displayId: "display-created",
});
});
test("skips responseId recovery while pending offline entries exist", async () => {
offlineStorageMocks.getSurveyProgress.mockResolvedValue(makeProgress());
offlineStorageMocks.countPendingResponses.mockResolvedValue(2);
renderSurvey();
await waitFor(() => {
expect(offlineStorageMocks.countPendingResponses).toHaveBeenCalled();
});
expect(apiClientMocks.getResponseIdByDisplayId).not.toHaveBeenCalled();
});
test("clears stale finished progress instead of restoring the ending card", async () => {
offlineStorageMocks.getSurveyProgress.mockResolvedValue(
makeProgress({
blockId: "ending-1",
surveyStateSnapshot: {
responseId: "response-finished",
displayId: "display1234567890123456789",
surveyId: baseSurvey.id,
singleUseId: null,
userId: null,
contactId: null,
responseAcc: {
finished: true,
data: { q1: "saved-answer", q2: "done" },
ttc: { q1: 111, q2: 123 },
variables: { savedVar: "saved" },
},
},
})
);
renderSurvey();
await waitFor(() => {
expect(offlineStorageMocks.clearSurveyProgress).toHaveBeenCalledWith(baseSurvey.id);
});
expect(apiClientMocks.getResponseIdByDisplayId).not.toHaveBeenCalled();
});
});
@@ -29,6 +29,7 @@ import {
type SerializedSurveyState,
clearSurveyProgress,
getSurveyProgress,
patchSurveyProgressSnapshot,
saveSurveyProgress,
} from "@/lib/offline-storage";
import { parseRecallInformation } from "@/lib/recall";
@@ -38,13 +39,28 @@ import { useOnlineStatus } from "@/lib/use-online-status";
import { cn, findBlockByElementId, getDefaultLanguageCode, getElementsFromSurveyBlocks } from "@/lib/utils";
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
const restoreSurveyStateFromSnapshot = (surveyState: SurveyState, snapshot: SerializedSurveyState): void => {
const restoreSurveyStateFromSnapshot = (
surveyState: SurveyState,
snapshot: SerializedSurveyState,
progress: {
responseData: TResponseData;
ttc: TResponseTtc;
currentVariables: TResponseVariables;
}
): void => {
if (snapshot.responseId) surveyState.updateResponseId(snapshot.responseId);
if (snapshot.displayId) surveyState.updateDisplayId(snapshot.displayId);
if (snapshot.userId) surveyState.updateUserId(snapshot.userId);
if (snapshot.contactId) surveyState.updateContactId(snapshot.contactId);
if (snapshot.singleUseId) surveyState.singleUseId = snapshot.singleUseId;
surveyState.responseAcc = snapshot.responseAcc;
surveyState.disableBootstrapResponseCreate();
surveyState.responseAcc = {
...snapshot.responseAcc,
data: progress.responseData,
ttc: progress.ttc,
variables: progress.currentVariables,
displayId: snapshot.displayId ?? snapshot.responseAcc.displayId,
};
};
interface VariableStackEntry {
@@ -127,6 +143,14 @@ export function Survey({
const offlinePersistEnabled =
offlineSupport && isLinkSurvey && !isPreviewMode && !!appUrl && !!environmentId;
const persistSurveyStateSnapshot = useCallback(
async (snapshotPatch: Partial<SerializedSurveyState>) => {
if (!offlinePersistEnabled) return;
await patchSurveyProgressSnapshot(survey.id, snapshotPatch);
},
[offlinePersistEnabled, survey.id]
);
const responseQueue = useMemo(() => {
if (appUrl && environmentId && surveyState) {
return new ResponseQueue(
@@ -160,6 +184,9 @@ export function Survey({
setBlockId(quotaInfo.endingCardId);
}
},
onResponseCreated: (responseId) => {
void persistSurveyStateSnapshot({ responseId });
},
},
surveyState
);
@@ -173,6 +200,7 @@ export function Survey({
getSetIsResponseSendingFinished,
surveyState,
offlinePersistEnabled,
persistSurveyStateSnapshot,
survey.id,
]);
@@ -319,6 +347,7 @@ export function Survey({
surveyState.updateDisplayId(display.data.id);
responseQueue.updateSurveyState(surveyState);
await persistSurveyStateSnapshot({ displayId: display.data.id });
if (onDisplayCreated) {
onDisplayCreated();
@@ -337,6 +366,7 @@ export function Survey({
onDisplayCreated,
isPreviewMode,
onDisplay,
persistSurveyStateSnapshot,
]);
// Create display on mount. When offline persistence is enabled, wait for progress
@@ -458,7 +488,36 @@ export function Survey({
// Restore survey state from snapshot
if (surveyState && progress.surveyStateSnapshot) {
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot);
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot, progress);
if (pendingCount === 0 && !progress.surveyStateSnapshot.responseId) {
if (progress.surveyStateSnapshot.displayId && apiClient) {
const responseLookup = await apiClient.getResponseIdByDisplayId(
progress.surveyStateSnapshot.displayId
);
if (responseLookup.ok && responseLookup.data.responseId) {
surveyState.updateResponseId(responseLookup.data.responseId);
await persistSurveyStateSnapshot({ responseId: responseLookup.data.responseId });
} else if (responseLookup.ok) {
surveyState.enableBootstrapResponseCreate();
} else if (responseLookup.error.status === 404) {
surveyState.updateDisplayId(null);
surveyState.enableBootstrapResponseCreate();
await persistSurveyStateSnapshot({ displayId: null });
} else {
console.error("Formbricks: Failed to recover responseId from displayId", {
displayId: progress.surveyStateSnapshot.displayId,
error: responseLookup.error,
});
surveyState.enableBootstrapResponseCreate();
}
} else {
surveyState.enableBootstrapResponseCreate();
}
}
responseQueue?.updateSurveyState(surveyState);
}
} else {
// Block no longer exists (survey structure changed) — discard UI progress
@@ -466,7 +525,8 @@ export function Survey({
await clearSurveyProgress(survey.id);
if (surveyState && progress.surveyStateSnapshot) {
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot);
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot, progress);
responseQueue?.updateSurveyState(surveyState);
}
}
@@ -94,6 +94,40 @@ describe("ApiClient", () => {
});
});
describe("getResponseIdByDisplayId", () => {
test("gets a linked responseId for a display", async () => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ ok: true, data: { responseId: "response123" } }),
} as unknown as Response);
const result = await client.getResponseIdByDisplayId("display123");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.responseId).toBe("response123");
}
expect(vi.mocked(global.fetch).mock.calls[0][0]).toContain(
"/api/v1/client/env-test/displays/display123/response"
);
});
test("returns an error if the display lookup fails", async () => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({ code: "not_found" }),
} as unknown as Response);
const result = await client.getResponseIdByDisplayId("missing-display");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.status).toBe(404);
}
});
});
describe("updateResponse", () => {
test("updates a response successfully", async () => {
vi.mocked(global.fetch).mockResolvedValueOnce({
+10
View File
@@ -46,6 +46,16 @@ export class ApiClient {
);
}
async getResponseIdByDisplayId(
displayId: string
): Promise<Result<{ responseId: string | null }, ApiErrorResponse>> {
return makeRequest(
this.appUrl,
`/api/v1/client/${this.environmentId}/displays/${displayId}/response`,
"GET"
);
}
async createResponse(
responseInput: Omit<TResponseInput, "environmentId"> & {
contactId: string | null;
+110 -2
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
getAutoProgressElement,
isSingleSelectOtherSelected,
shouldHideSubmitButtonForAutoProgress,
shouldTriggerAutoProgress,
} from "./auto-progress";
@@ -13,26 +14,62 @@ const createElement = (id: string, type: TSurveyElementTypeEnum, required: boole
required,
}) as unknown as TSurveyElement;
const createSingleSelectElement = (required: boolean): TSurveyElement =>
({
id: "single_select_1",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
required,
choices: [
{ id: "choice_1", label: { default: "Choice 1", de: "Auswahl 1" } },
{ id: "choice_2", label: { default: "Choice 2" } },
{ id: "other", label: { default: "Other" } },
],
}) as unknown as TSurveyElement;
const createPictureSelectionElement = (required: boolean, allowMulti: boolean): TSurveyElement =>
({
id: "picture_1",
type: TSurveyElementTypeEnum.PictureSelection,
required,
allowMulti,
choices: [
{ id: "pic_1", imageUrl: "https://example.com/1.png" },
{ id: "pic_2", imageUrl: "https://example.com/2.png" },
],
}) as unknown as TSurveyElement;
describe("auto-progress helpers", () => {
test("returns auto-progress element for single rating/nps blocks only", () => {
test("returns auto-progress element for all supported single-question types only", () => {
const ratingElement = createElement("rating_1", TSurveyElementTypeEnum.Rating, true);
const npsElement = createElement("nps_1", TSurveyElementTypeEnum.NPS, false);
const singleSelectElement = createSingleSelectElement(false);
const singlePictureElement = createPictureSelectionElement(false, false);
const multiPictureElement = createPictureSelectionElement(false, true);
const openTextElement = createElement("text_1", TSurveyElementTypeEnum.OpenText, false);
expect(getAutoProgressElement([ratingElement], true)).toEqual(ratingElement);
expect(getAutoProgressElement([npsElement], true)).toEqual(npsElement);
expect(getAutoProgressElement([singleSelectElement], true)).toEqual(singleSelectElement);
expect(getAutoProgressElement([singlePictureElement], true)).toEqual(singlePictureElement);
expect(getAutoProgressElement([multiPictureElement], true)).toBeNull();
expect(getAutoProgressElement([openTextElement], true)).toBeNull();
expect(getAutoProgressElement([ratingElement], false)).toBeNull();
expect(getAutoProgressElement([ratingElement, npsElement], true)).toBeNull();
});
test("hides submit button only for required auto-progress elements", () => {
test("hides submit button only for required auto-progress elements with no required+other exception", () => {
const requiredRating = createElement("rating_required", TSurveyElementTypeEnum.Rating, true);
const optionalRating = createElement("rating_optional", TSurveyElementTypeEnum.Rating, false);
const requiredSingleSelect = createSingleSelectElement(true);
expect(shouldHideSubmitButtonForAutoProgress([requiredRating], true)).toBe(true);
expect(shouldHideSubmitButtonForAutoProgress([optionalRating], true)).toBe(false);
expect(shouldHideSubmitButtonForAutoProgress([requiredRating], false)).toBe(false);
expect(
shouldHideSubmitButtonForAutoProgress([requiredSingleSelect], true, {
[requiredSingleSelect.id]: "",
})
).toBe(false);
});
test("triggers auto-progress only when an eligible response was changed", () => {
@@ -73,5 +110,76 @@ describe("auto-progress helpers", () => {
isAlreadyInFlight: true,
})
).toBe(false);
expect(
shouldTriggerAutoProgress({
changedElementId: "rating_1",
mergedValue: { rating_1: 5 },
autoProgressElement,
isAlreadyInFlight: false,
isCommittedSelection: false,
})
).toBe(false);
});
test("detects single-select other selection from sentinel and custom values", () => {
const autoProgressElement = createSingleSelectElement(true);
expect(
isSingleSelectOtherSelected({
autoProgressElement,
mergedValue: { [autoProgressElement.id]: "" },
})
).toBe(true);
expect(
isSingleSelectOtherSelected({
autoProgressElement,
mergedValue: { [autoProgressElement.id]: "Custom answer" },
})
).toBe(true);
expect(
isSingleSelectOtherSelected({
autoProgressElement,
mergedValue: { [autoProgressElement.id]: "Choice 1" },
})
).toBe(false);
expect(
isSingleSelectOtherSelected({
autoProgressElement,
mergedValue: { [autoProgressElement.id]: "Auswahl 1" },
})
).toBe(false);
expect(
isSingleSelectOtherSelected({
autoProgressElement,
mergedValue: { [autoProgressElement.id]: "choice_1" },
})
).toBe(false);
});
test("does not auto-progress when single-select other is selected", () => {
const autoProgressElement = createSingleSelectElement(true);
expect(
shouldTriggerAutoProgress({
changedElementId: autoProgressElement.id,
mergedValue: { [autoProgressElement.id]: "" },
autoProgressElement,
isAlreadyInFlight: false,
})
).toBe(false);
expect(
shouldTriggerAutoProgress({
changedElementId: autoProgressElement.id,
mergedValue: { [autoProgressElement.id]: "Custom answer" },
autoProgressElement,
isAlreadyInFlight: false,
})
).toBe(false);
});
});
+83 -6
View File
@@ -1,8 +1,65 @@
import { type TResponseData } from "@formbricks/types/responses";
import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
const isAutoProgressElementType = (type: TSurveyElementTypeEnum): boolean =>
type === TSurveyElementTypeEnum.Rating || type === TSurveyElementTypeEnum.NPS;
const isAutoProgressElement = (element: TSurveyElement): boolean => {
if (element.type === TSurveyElementTypeEnum.Rating || element.type === TSurveyElementTypeEnum.NPS) {
return true;
}
if (element.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
return true;
}
if (element.type === TSurveyElementTypeEnum.PictureSelection) {
return !element.allowMulti;
}
return false;
};
const getAllChoiceLabels = (
element: Extract<TSurveyElement, { type: TSurveyElementTypeEnum.MultipleChoiceSingle }>
) =>
element.choices.filter((choice) => choice.id !== "other").flatMap((choice) => Object.values(choice.label));
export const isSingleSelectOtherSelected = ({
autoProgressElement,
mergedValue,
}: {
autoProgressElement: TSurveyElement | null;
mergedValue: TResponseData;
}): boolean => {
if (!autoProgressElement || autoProgressElement.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) {
return false;
}
const hasOtherOption = autoProgressElement.choices.some((choice) => choice.id === "other");
if (!hasOtherOption) {
return false;
}
const currentValue = mergedValue[autoProgressElement.id];
if (currentValue === undefined) {
return false;
}
if (currentValue === "") {
return true;
}
if (typeof currentValue !== "string") {
return false;
}
const regularChoiceIds = autoProgressElement.choices
.filter((choice) => choice.id !== "other")
.map((choice) => choice.id);
if (regularChoiceIds.includes(currentValue)) {
return false;
}
return !getAllChoiceLabels(autoProgressElement).includes(currentValue);
};
export const getAutoProgressElement = (
elements: TSurveyElement[],
@@ -13,15 +70,24 @@ export const getAutoProgressElement = (
}
const [element] = elements;
return isAutoProgressElementType(element.type) ? element : null;
return isAutoProgressElement(element) ? element : null;
};
export const shouldHideSubmitButtonForAutoProgress = (
elements: TSurveyElement[],
isAutoProgressingEnabled: boolean
isAutoProgressingEnabled: boolean,
mergedValue: TResponseData = {}
): boolean => {
const autoProgressElement = getAutoProgressElement(elements, isAutoProgressingEnabled);
return Boolean(autoProgressElement?.required);
if (!autoProgressElement?.required) {
return false;
}
if (isSingleSelectOtherSelected({ autoProgressElement, mergedValue })) {
return false;
}
return true;
};
export const shouldTriggerAutoProgress = ({
@@ -29,13 +95,24 @@ export const shouldTriggerAutoProgress = ({
mergedValue,
autoProgressElement,
isAlreadyInFlight,
isCommittedSelection = true,
}: {
changedElementId: string;
mergedValue: TResponseData;
autoProgressElement: TSurveyElement | null;
isAlreadyInFlight: boolean;
isCommittedSelection?: boolean;
}): boolean => {
if (!autoProgressElement || isAlreadyInFlight || changedElementId !== autoProgressElement.id) {
if (
!autoProgressElement ||
isAlreadyInFlight ||
changedElementId !== autoProgressElement.id ||
!isCommittedSelection
) {
return false;
}
if (isSingleSelectOtherSelected({ autoProgressElement, mergedValue })) {
return false;
}
@@ -8,6 +8,7 @@ import {
countPendingResponses,
getPendingResponses,
getSurveyProgress,
patchSurveyProgressSnapshot,
removePendingResponse,
saveSurveyProgress,
} from "./offline-storage";
@@ -213,6 +214,34 @@ describe("offline-storage (IndexedDB)", () => {
expect(result?.blockId).toBe("block-2");
});
test("patchSurveyProgressSnapshot updates only the saved snapshot anchors", async () => {
await saveSurveyProgress({
surveyId: "survey-1",
...makeProgress({
surveyStateSnapshot: makeSurveyStateSnapshot({
responseId: null,
displayId: "display-1",
responseAcc: { finished: false, data: { q1: "answer" }, ttc: { q1: 1500 }, variables: {} },
}),
}),
});
await patchSurveyProgressSnapshot("survey-1", { responseId: "response-1" });
const result = await getSurveyProgress("survey-1");
expect(result?.blockId).toBe("block-1");
expect(result?.responseData).toEqual({ q1: "answer" });
expect(result?.surveyStateSnapshot.responseId).toBe("response-1");
expect(result?.surveyStateSnapshot.displayId).toBe("display-1");
});
test("patchSurveyProgressSnapshot is a no-op when survey progress does not exist", async () => {
await expect(patchSurveyProgressSnapshot("missing-survey", { responseId: "response-1" })).resolves.toBe(
undefined
);
expect(await getSurveyProgress("missing-survey")).toBeUndefined();
});
test("getSurveyProgress returns undefined for non-existent surveyId", async () => {
const result = await getSurveyProgress("non-existent");
expect(result).toBeUndefined();

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