mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 11:28:58 -05:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4018d558a1 | |||
| c9a5d1a81e | |||
| cefc2bdf60 | |||
| 78473bf3d0 | |||
| 15403c6a92 | |||
| 35b98863a4 | |||
| 65f5968fb1 | |||
| 2dfea4d72f | |||
| ff77118932 | |||
| 79a773432a | |||
| d53869f1df | |||
| fc9ddb2b0d | |||
| 6fcb6863bd | |||
| b1cee91ad9 | |||
| d7eb158ccd | |||
| 0729798537 |
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+5
-1
@@ -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>;
|
||||
};
|
||||
+98
@@ -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);
|
||||
});
|
||||
});
|
||||
+44
@@ -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;
|
||||
}
|
||||
};
|
||||
+70
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
@@ -1647,14 +1647,14 @@ const identifyCustomerGoals = (t: TFunction): TTemplate => {
|
||||
elements: [
|
||||
buildMultipleChoiceElement({
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
headline: "What's your primary goal for using $[projectName]?",
|
||||
headline: t("templates.identify_customer_goals_question_1_headline"),
|
||||
required: true,
|
||||
shuffleOption: "none",
|
||||
choices: [
|
||||
"Understand my user base deeply",
|
||||
"Identify upselling opportunities",
|
||||
"Build the best possible product",
|
||||
"Rule the world to make everyone breakfast brussels sprouts.",
|
||||
t("templates.identify_customer_goals_question_1_choice_1"),
|
||||
t("templates.identify_customer_goals_question_1_choice_2"),
|
||||
t("templates.identify_customer_goals_question_1_choice_3"),
|
||||
t("templates.identify_customer_goals_question_1_choice_4"),
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
+15
-18
@@ -315,7 +315,6 @@ checksums:
|
||||
common/other: 79acaa6cd481262bea4e743a422529d2
|
||||
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
|
||||
common/other_placeholder: f3a0fa2eaaf75aa92b290449c928c081
|
||||
common/others: 39160224ce0e35eb4eb252c997edf4d8
|
||||
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
|
||||
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
|
||||
common/password: 223a61cf906ab9c40d22612c588dff48
|
||||
@@ -333,7 +332,6 @@ checksums:
|
||||
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
|
||||
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
|
||||
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
|
||||
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
|
||||
common/privacy: 7459744a63ef8af4e517a09024bd7c08
|
||||
common/product_manager: dfeadc96e6d3de22a884ee97974b505e
|
||||
common/production: 226e0ce83b49700bc1b1c08c4c3ed23a
|
||||
@@ -468,7 +466,6 @@ checksums:
|
||||
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
|
||||
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
|
||||
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
|
||||
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
|
||||
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
|
||||
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
|
||||
common/you_have_reached_your_limit_of_workspace_limit: 54d754c3267036742f23fb05fd3fcc45
|
||||
@@ -1239,12 +1236,7 @@ checksums:
|
||||
environments/settings/teams/you_are_a_member: cf5af638d5371c8fbc337e92519e5150
|
||||
environments/surveys/all_set_time_to_create_first_survey: 21d3bb74c3b9642b3195d17c17346399
|
||||
environments/surveys/alphabetical: 5fcfeff9c5fd28714f0a390e0ddaaaee
|
||||
environments/surveys/copy_survey: de8142b45e7bca61f2dca0069a62b417
|
||||
environments/surveys/copy_survey_description: 66d0aadf192ad5790fbf3f55f3bb5485
|
||||
environments/surveys/copy_survey_error: 74cab7d84ea8b669e106d4c326cac005
|
||||
environments/surveys/copy_survey_link_to_clipboard: 77387e3d3de4be07a2a34963f73cd7e8
|
||||
environments/surveys/copy_survey_partially_success: a436a5fb7167b95c2308794d35aab070
|
||||
environments/surveys/copy_survey_success: a829e645fe034b3e712d0b8572a5edc4
|
||||
environments/surveys/delete_survey_and_responses_warning: 3320c91c1fd27378b7f3d6abc003f2ae
|
||||
environments/surveys/edit/activate_translations: af127c1bed2b47e2012e3a23e489ecb8
|
||||
environments/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66
|
||||
@@ -1296,7 +1288,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
|
||||
@@ -1965,7 +1957,6 @@ checksums:
|
||||
environments/surveys/summary/downloading_qr_code: 3c46bf636e617848a4fca9b6c5b51dac
|
||||
environments/surveys/summary/drop_offs: 605ee950f82110132d6c5780926af109
|
||||
environments/surveys/summary/drop_offs_tooltip: 2a01683380be45f17636365886cf3452
|
||||
environments/surveys/summary/failed_to_copy_link: 4e891c757c80e770674e8e74d1c08487
|
||||
environments/surveys/summary/filter_added_successfully: e247f65020cd87454bcec0da6f0fd034
|
||||
environments/surveys/summary/filter_updated_successfully: 01146bc7e6394e271836be2f1b3a257b
|
||||
environments/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
|
||||
@@ -2050,7 +2041,6 @@ checksums:
|
||||
environments/surveys/summary/youre_not_plugged_in_yet: f19da3cd474b9a3cf28e956fd811fb00
|
||||
environments/surveys/survey_deleted_successfully: a6b654cc914b344a4475fd2fd4a98cc5
|
||||
environments/surveys/survey_duplicated_successfully: 91e244f1e7a33640bb4817166a01ff46
|
||||
environments/surveys/survey_duplication_error: 35994330aed844ce37d8b4f09df24581
|
||||
environments/surveys/templates/all_channels: 6be67a82fc7326dc2304b23ab3348b87
|
||||
environments/surveys/templates/all_industries: c7354412fe34585526ff2232aadace41
|
||||
environments/surveys/templates/all_roles: 6582ccd0a2349c162a7ae1574cdf76be
|
||||
@@ -2194,12 +2184,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
|
||||
@@ -2732,6 +2722,11 @@ checksums:
|
||||
templates/gauge_feature_satisfaction_question_2_headline: 0fcbefbfcf5c21e42de8a36cb2cad854
|
||||
templates/identify_customer_goals_description: c30d06df9e5c76334e4c3d470ee6e4d8
|
||||
templates/identify_customer_goals_name: f8123dbfa22e169517a811fae7496595
|
||||
templates/identify_customer_goals_question_1_choice_1: a6803cfbdbd6208eedf5c691f9e106a5
|
||||
templates/identify_customer_goals_question_1_choice_2: 7461749517d62030ec2e3915cf1d223b
|
||||
templates/identify_customer_goals_question_1_choice_3: 725eb3ee0d4f2d229fcf588c21e66a86
|
||||
templates/identify_customer_goals_question_1_choice_4: 3985521036afaf1cbd2bdc7a4d86d351
|
||||
templates/identify_customer_goals_question_1_headline: bd9cd414fb723110d7f0a786bbf89d6c
|
||||
templates/identify_sign_up_barriers_description: 5b2fbee8c425d7a4d0706ec3628cea11
|
||||
templates/identify_sign_up_barriers_name: 3bbc5352dfa7a9c237bc2c6b21b608dd
|
||||
templates/identify_sign_up_barriers_question_1_button_label: 080fd22c580f56ffdcea6c3d60448b84
|
||||
@@ -2806,12 +2801,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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
+12
-15
@@ -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",
|
||||
@@ -2887,6 +2877,11 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "Was könnten wir besser machen?",
|
||||
"identify_customer_goals_description": "Besser verstehen, ob deine Botschaften die richtigen Erwartungen an dein Produkt schaffen.",
|
||||
"identify_customer_goals_name": "Kundenziele identifizieren",
|
||||
"identify_customer_goals_question_1_choice_1": "Meine Nutzerbasis tiefgehend verstehen",
|
||||
"identify_customer_goals_question_1_choice_2": "Upselling-Möglichkeiten identifizieren",
|
||||
"identify_customer_goals_question_1_choice_3": "Das bestmögliche Produkt entwickeln",
|
||||
"identify_customer_goals_question_1_choice_4": "Die Welt beherrschen, um allen Rosenkohl zum Frühstück zu servieren",
|
||||
"identify_customer_goals_question_1_headline": "Was ist Ihr Hauptziel bei der Nutzung von $[projectName]?",
|
||||
"identify_sign_up_barriers_description": "Biete einen Rabatt an, um Einblicke in Anmeldebarrieren zu gewinnen.",
|
||||
"identify_sign_up_barriers_name": "Identifiziere Anmeldebarrieren",
|
||||
"identify_sign_up_barriers_question_1_button_label": "Erhalte 10% Rabatt",
|
||||
@@ -2961,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.",
|
||||
|
||||
@@ -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",
|
||||
@@ -2887,6 +2877,11 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "What is one thing we could do better?",
|
||||
"identify_customer_goals_description": "Better understand if your messaging creates the right expectations of the value your product provides.",
|
||||
"identify_customer_goals_name": "Identify Customer Goals",
|
||||
"identify_customer_goals_question_1_choice_1": "Understand my user base deeply",
|
||||
"identify_customer_goals_question_1_choice_2": "Identify upselling opportunities",
|
||||
"identify_customer_goals_question_1_choice_3": "Build the best possible product",
|
||||
"identify_customer_goals_question_1_choice_4": "Rule the world to make everyone breakfast brussels sprouts",
|
||||
"identify_customer_goals_question_1_headline": "What is your primary goal for using $[projectName]?",
|
||||
"identify_sign_up_barriers_description": "Offer a discount to gather insights about sign up barriers.",
|
||||
"identify_sign_up_barriers_name": "Identify Sign Up Barriers",
|
||||
"identify_sign_up_barriers_question_1_button_label": "Get 10% discount",
|
||||
@@ -2961,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.",
|
||||
|
||||
+14
-17
@@ -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",
|
||||
@@ -2887,6 +2877,11 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "¿Qué es una cosa que podríamos mejorar?",
|
||||
"identify_customer_goals_description": "Comprende mejor si tus mensajes crean las expectativas correctas sobre el valor que proporciona tu producto.",
|
||||
"identify_customer_goals_name": "Identificar objetivos del cliente",
|
||||
"identify_customer_goals_question_1_choice_1": "Comprender en profundidad a mi base de usuarios",
|
||||
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de venta adicional",
|
||||
"identify_customer_goals_question_1_choice_3": "Construir el mejor producto posible",
|
||||
"identify_customer_goals_question_1_choice_4": "Conquistar el mundo para que todos desayunen coles de Bruselas",
|
||||
"identify_customer_goals_question_1_headline": "¿Cuál es tu objetivo principal al usar $[projectName]?",
|
||||
"identify_sign_up_barriers_description": "Ofrece un descuento para obtener información sobre las barreras de registro.",
|
||||
"identify_sign_up_barriers_name": "Identificar barreras de registro",
|
||||
"identify_sign_up_barriers_question_1_button_label": "Obtener 10 % de descuento",
|
||||
@@ -2961,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.",
|
||||
|
||||
+12
-15
@@ -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électionné.",
|
||||
"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",
|
||||
@@ -2887,6 +2877,11 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "Quelle est une chose que nous pourrions améliorer ?",
|
||||
"identify_customer_goals_description": "Mieux comprendre si votre message crée les bonnes attentes quant à la valeur que votre produit apporte.",
|
||||
"identify_customer_goals_name": "Identifier les objectifs des clients",
|
||||
"identify_customer_goals_question_1_choice_1": "Comprendre ma base d'utilisateurs en profondeur",
|
||||
"identify_customer_goals_question_1_choice_2": "Identifier des opportunités de montée en gamme",
|
||||
"identify_customer_goals_question_1_choice_3": "Créer le meilleur produit possible",
|
||||
"identify_customer_goals_question_1_choice_4": "Conquérir le monde pour imposer des choux de Bruxelles au petit-déjeuner à tout le monde",
|
||||
"identify_customer_goals_question_1_headline": "Quel est votre objectif principal pour l'utilisation de $[projectName] ?",
|
||||
"identify_sign_up_barriers_description": "Offrir une remise pour recueillir des informations sur les obstacles à l'inscription.",
|
||||
"identify_sign_up_barriers_name": "Identifier les obstacles à l'inscription",
|
||||
"identify_sign_up_barriers_question_1_button_label": "Obtenez 10 % de réduction",
|
||||
@@ -2961,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.",
|
||||
|
||||
+12
-15
@@ -342,7 +342,6 @@
|
||||
"other": "Egyéb",
|
||||
"other_filters": "Egyéb szűrők",
|
||||
"other_placeholder": "Egyéb helyőrző",
|
||||
"others": "Mások",
|
||||
"overlay_color": "Rávetítés színe",
|
||||
"overview": "Áttekintés",
|
||||
"password": "Jelszó",
|
||||
@@ -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",
|
||||
@@ -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.",
|
||||
@@ -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",
|
||||
@@ -1367,7 +1359,7 @@
|
||||
"audience": "Közönség",
|
||||
"auto_close_on_inactivity": "Automatikus lezárás tétlenségnél",
|
||||
"auto_progress_rating_and_nps": "Automatikus továbblépés értékelési és NPS kérdéseknél",
|
||||
"auto_progress_rating_and_nps_description": "Automatikus továbblépés, 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_description": "Automatikus továbblépés egy kérdést tartalmazó blokkokban. A kötelező kérdések elrejtik a Tovább gombot, kivéve amikor az „Egyéb“ opció van kiválasztva.",
|
||||
"auto_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",
|
||||
@@ -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)",
|
||||
@@ -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",
|
||||
@@ -2312,11 +2302,11 @@
|
||||
"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": "A beviteli mezők feletti kis címkék és a skálacímkék színét állítja be.",
|
||||
"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": "A beviteli mezők feletti kis címkék és a skálacímkék betűméretét állítja be.",
|
||||
"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_field_upper_label_weight_description": "A címkék vékonyabbá vagy vastagabbá tételét teszi lehetővé.",
|
||||
"advanced_styling_section_buttons": "Gombok",
|
||||
"advanced_styling_section_headlines": "Címsorok és leírások",
|
||||
"advanced_styling_section_inputs": "Beviteli mezők",
|
||||
@@ -2887,6 +2877,11 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "Mi az egyetlen dolog, amelyet jobban csinálhatnánk?",
|
||||
"identify_customer_goals_description": "Jobban megérteni, hogy az üzenetei a termék által nyújtott érték megfelelő elvárásait keltik-e.",
|
||||
"identify_customer_goals_name": "Ügyfélcélok azonosítása",
|
||||
"identify_customer_goals_question_1_choice_1": "Alaposan megismerni a felhasználói bázisomat",
|
||||
"identify_customer_goals_question_1_choice_2": "Felülértékesítési lehetőségek azonosítása",
|
||||
"identify_customer_goals_question_1_choice_3": "A lehető legjobb termék elkészítése",
|
||||
"identify_customer_goals_question_1_choice_4": "Világuralmat szerezni, hogy mindenki kelbimbót egyen reggelire",
|
||||
"identify_customer_goals_question_1_headline": "Mi az elsődleges célja a(z) $[projectName] használatával?",
|
||||
"identify_sign_up_barriers_description": "Kedvezmény felajánlása a regisztrációs akadályokkal kapcsolatos tapasztalatok gyűjtéséhez.",
|
||||
"identify_sign_up_barriers_name": "Regisztrációs akadályok azonosítása",
|
||||
"identify_sign_up_barriers_question_1_button_label": "10% kedvezmény",
|
||||
@@ -2961,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árt 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": "Kérjük, ismertesse 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.",
|
||||
|
||||
+13
-16
@@ -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": "入力フィールド",
|
||||
@@ -2887,6 +2877,11 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "私たちがもっとうまくできることは何ですか?",
|
||||
"identify_customer_goals_description": "あなたのメッセージが製品の価値に対する正しい期待を抱かせているかどうかをよりよく理解する。",
|
||||
"identify_customer_goals_name": "顧客目標の特定",
|
||||
"identify_customer_goals_question_1_choice_1": "ユーザーベースを深く理解する",
|
||||
"identify_customer_goals_question_1_choice_2": "アップセルの機会を特定する",
|
||||
"identify_customer_goals_question_1_choice_3": "最高の製品を構築する",
|
||||
"identify_customer_goals_question_1_choice_4": "世界を支配して全員に朝食に芽キャベツを食べさせる",
|
||||
"identify_customer_goals_question_1_headline": "$[projectName]を使用する主な目的は何ですか?",
|
||||
"identify_sign_up_barriers_description": "サインアップの障壁に関する洞察を得るために割引を提供する。",
|
||||
"identify_sign_up_barriers_name": "サインアップの障壁を特定する",
|
||||
"identify_sign_up_barriers_question_1_button_label": "10%割引を取得",
|
||||
@@ -2961,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": "ユーザーが製品に統合を追加するのがどれだけ簡単かを評価する。盲点を見つける。",
|
||||
|
||||
+11
-14
@@ -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",
|
||||
@@ -2887,6 +2877,11 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "Wat kunnen we beter doen?",
|
||||
"identify_customer_goals_description": "Begrijp beter of uw boodschap de juiste verwachtingen wekt van de waarde die uw product biedt.",
|
||||
"identify_customer_goals_name": "Identificeer klantdoelen",
|
||||
"identify_customer_goals_question_1_choice_1": "Mijn gebruikersgroep grondig begrijpen",
|
||||
"identify_customer_goals_question_1_choice_2": "Upselling-mogelijkheden identificeren",
|
||||
"identify_customer_goals_question_1_choice_3": "Het best mogelijke product bouwen",
|
||||
"identify_customer_goals_question_1_choice_4": "De wereld regeren om iedereen spruitjes als ontbijt te geven",
|
||||
"identify_customer_goals_question_1_headline": "Wat is je primaire doel voor het gebruik van $[projectName]?",
|
||||
"identify_sign_up_barriers_description": "Bied een korting aan om inzicht te krijgen in de aanmeldingsbarrières.",
|
||||
"identify_sign_up_barriers_name": "Identificeer aanmeldingsbarrières",
|
||||
"identify_sign_up_barriers_question_1_button_label": "Krijg 10% korting",
|
||||
@@ -2961,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.",
|
||||
|
||||
+15
-18
@@ -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",
|
||||
@@ -2887,6 +2877,11 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "O que a gente poderia melhorar?",
|
||||
"identify_customer_goals_description": "Entenda melhor se sua mensagem cria as expectativas certas sobre o valor que seu produto oferece.",
|
||||
"identify_customer_goals_name": "Identificar Objetivos do Cliente",
|
||||
"identify_customer_goals_question_1_choice_1": "Entender profundamente minha base de usuários",
|
||||
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de upsell",
|
||||
"identify_customer_goals_question_1_choice_3": "Construir o melhor produto possível",
|
||||
"identify_customer_goals_question_1_choice_4": "Dominar o mundo para fazer todo mundo tomar couve de bruxelas no café da manhã",
|
||||
"identify_customer_goals_question_1_headline": "Qual é o seu objetivo principal ao usar $[projectName]?",
|
||||
"identify_sign_up_barriers_description": "Ofereça um desconto pra entender melhor as barreiras de cadastro.",
|
||||
"identify_sign_up_barriers_name": "Identificar Barreiras de Cadastro",
|
||||
"identify_sign_up_barriers_question_1_button_label": "Ganhe 10% de desconto",
|
||||
@@ -2961,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.",
|
||||
|
||||
+14
-17
@@ -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",
|
||||
@@ -2887,6 +2877,11 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "O que é uma coisa que poderíamos fazer melhor?",
|
||||
"identify_customer_goals_description": "Compreenda melhor se a sua mensagem cria as expectativas certas sobre o valor que o seu produto oferece.",
|
||||
"identify_customer_goals_name": "Identificar Objetivos do Cliente",
|
||||
"identify_customer_goals_question_1_choice_1": "Compreender profundamente a minha base de utilizadores",
|
||||
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de upselling",
|
||||
"identify_customer_goals_question_1_choice_3": "Construir o melhor produto possível",
|
||||
"identify_customer_goals_question_1_choice_4": "Dominar o mundo para fazer couves de Bruxelas ao pequeno-almoço para todos",
|
||||
"identify_customer_goals_question_1_headline": "Qual é o seu objetivo principal ao usar $[projectName]?",
|
||||
"identify_sign_up_barriers_description": "Ofereça um desconto para obter informações sobre as barreiras de inscrição.",
|
||||
"identify_sign_up_barriers_name": "Identificar Barreiras de Inscrição",
|
||||
"identify_sign_up_barriers_question_1_button_label": "Obtenha 10% de desconto",
|
||||
@@ -2961,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.",
|
||||
|
||||
+12
-15
@@ -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": "Redimensionează 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",
|
||||
@@ -2887,6 +2877,11 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "Care este acel lucru pe care l-am putea îmbunătăți?",
|
||||
"identify_customer_goals_description": "Înțelegeți mai bine dacă mesajele voastre creează așteptările corecte privind valoarea pe care o oferă produsul vostru.",
|
||||
"identify_customer_goals_name": "Identifică Obiectivele Clienților",
|
||||
"identify_customer_goals_question_1_choice_1": "Să îmi înțeleg în profunzime baza de utilizatori",
|
||||
"identify_customer_goals_question_1_choice_2": "Să identific oportunități de upselling",
|
||||
"identify_customer_goals_question_1_choice_3": "Să construiesc cel mai bun produs posibil",
|
||||
"identify_customer_goals_question_1_choice_4": "Să cuceresc lumea pentru a-i face tuturor la micul dejun varză de Bruxelles",
|
||||
"identify_customer_goals_question_1_headline": "Care este obiectivul tău principal pentru utilizarea $[projectName]?",
|
||||
"identify_sign_up_barriers_description": "Oferiți o reducere pentru a obține informații despre barierele de înscriere.",
|
||||
"identify_sign_up_barriers_name": "Identificați barierele de înscriere",
|
||||
"identify_sign_up_barriers_question_1_button_label": "Obține reducere de 10%",
|
||||
@@ -2961,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": "Vă 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.",
|
||||
|
||||
+15
-18
@@ -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": "Поля ввода",
|
||||
@@ -2887,6 +2877,11 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "Что мы могли бы сделать лучше?",
|
||||
"identify_customer_goals_description": "Лучше понять, создают ли ваши сообщения правильные ожидания относительно ценности вашего продукта.",
|
||||
"identify_customer_goals_name": "Определение целей клиента",
|
||||
"identify_customer_goals_question_1_choice_1": "Глубоко понять свою пользовательскую базу",
|
||||
"identify_customer_goals_question_1_choice_2": "Выявить возможности для допродаж",
|
||||
"identify_customer_goals_question_1_choice_3": "Создать наилучший продукт",
|
||||
"identify_customer_goals_question_1_choice_4": "Править миром, чтобы накормить всех брюссельской капустой на завтрак",
|
||||
"identify_customer_goals_question_1_headline": "Какова ваша основная цель использования $[projectName]?",
|
||||
"identify_sign_up_barriers_description": "Предложите скидку, чтобы узнать, что мешает регистрации.",
|
||||
"identify_sign_up_barriers_name": "Определение барьеров регистрации",
|
||||
"identify_sign_up_barriers_question_1_button_label": "Получить скидку 10%",
|
||||
@@ -2961,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": "Оцените, насколько легко пользователи могут добавлять интеграции в ваш продукт. Найдите слабые места.",
|
||||
|
||||
+15
-18
@@ -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",
|
||||
@@ -2887,6 +2877,11 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "Vad är en sak vi kunde göra bättre?",
|
||||
"identify_customer_goals_description": "Förstå bättre om din kommunikation skapar rätt förväntningar på värdet din produkt ger.",
|
||||
"identify_customer_goals_name": "Identifiera kundmål",
|
||||
"identify_customer_goals_question_1_choice_1": "Förstå min användarbas på djupet",
|
||||
"identify_customer_goals_question_1_choice_2": "Identifiera merförsäljningsmöjligheter",
|
||||
"identify_customer_goals_question_1_choice_3": "Bygga bästa möjliga produkt",
|
||||
"identify_customer_goals_question_1_choice_4": "Härska över världen för att få alla att äta brysselkål till frukost",
|
||||
"identify_customer_goals_question_1_headline": "Vad är ditt primära mål med att använda $[projectName]?",
|
||||
"identify_sign_up_barriers_description": "Erbjud en rabatt för att samla insikter om registreringshinder.",
|
||||
"identify_sign_up_barriers_name": "Identifiera registreringshinder",
|
||||
"identify_sign_up_barriers_question_1_button_label": "Få 10% rabatt",
|
||||
@@ -2961,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.",
|
||||
|
||||
@@ -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": "输入项",
|
||||
@@ -2887,6 +2877,11 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "我们 可以 改进 的 一 件事 是 什么?",
|
||||
"identify_customer_goals_description": "更好 地 了解 您 的 信息 是否 创造了 您 的 产品 所 提供 价值 的 正确 期望。",
|
||||
"identify_customer_goals_name": "识别 客户 目标",
|
||||
"identify_customer_goals_question_1_choice_1": "深入了解我的用户群体",
|
||||
"identify_customer_goals_question_1_choice_2": "识别追加销售机会",
|
||||
"identify_customer_goals_question_1_choice_3": "打造最优质的产品",
|
||||
"identify_customer_goals_question_1_choice_4": "统治世界,让每个人早餐都吃抱子甘蓝",
|
||||
"identify_customer_goals_question_1_headline": "您使用 $[projectName] 的主要目标是什么?",
|
||||
"identify_sign_up_barriers_description": "提供折扣以收集有关 注册障碍 的见解。",
|
||||
"identify_sign_up_barriers_name": "识别 注册 障碍",
|
||||
"identify_sign_up_barriers_question_1_button_label": "获取 10% 折扣",
|
||||
@@ -2961,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": "评估用户 添加 集成 到 产品 的 便捷程度 。 找到 盲点 。",
|
||||
|
||||
@@ -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": "輸入欄位",
|
||||
@@ -2887,6 +2877,11 @@
|
||||
"gauge_feature_satisfaction_question_2_headline": "我們可以做哪一件事來改進?",
|
||||
"identify_customer_goals_description": "更瞭解您的訊息傳遞是否符合您的產品所提供價值的正確期望。",
|
||||
"identify_customer_goals_name": "識別客戶目標",
|
||||
"identify_customer_goals_question_1_choice_1": "深入了解我的使用者群",
|
||||
"identify_customer_goals_question_1_choice_2": "找出向上銷售的機會",
|
||||
"identify_customer_goals_question_1_choice_3": "打造最優秀的產品",
|
||||
"identify_customer_goals_question_1_choice_4": "統治世界,讓每個人都吃早餐球芽甘藍",
|
||||
"identify_customer_goals_question_1_headline": "您使用 $[projectName] 的主要目標是什麼?",
|
||||
"identify_sign_up_barriers_description": "提供折扣以收集有關註冊障礙的洞察。",
|
||||
"identify_sign_up_barriers_name": "識別註冊障礙",
|
||||
"identify_sign_up_barriers_question_1_button_label": "獲得 10% 折扣",
|
||||
@@ -2961,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": "評估使用者將整合新增至您的產品的容易程度。找出盲點。",
|
||||
|
||||
+4
-3
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,7 @@ 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 })}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { deleteSurvey } from "./surveys";
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
|
||||
const environmentId = "clq5n7p1q0000m7z0h5p6g3r3";
|
||||
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
|
||||
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
|
||||
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
|
||||
|
||||
const mockDeletedSurveyAppPrivateSegment = {
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
type: "app",
|
||||
segment: { id: segmentId, isPrivate: true },
|
||||
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
|
||||
};
|
||||
|
||||
const mockDeletedSurveyLink = {
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
type: "link",
|
||||
segment: null,
|
||||
triggers: [],
|
||||
};
|
||||
|
||||
describe("deleteSurvey", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should delete a link survey without a segment", async () => {
|
||||
const deleteMock = vi.fn().mockResolvedValue(mockDeletedSurveyLink);
|
||||
const segmentDeleteMock = vi.fn();
|
||||
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
|
||||
callback({
|
||||
survey: { delete: deleteMock },
|
||||
segment: { delete: segmentDeleteMock },
|
||||
} as never)
|
||||
);
|
||||
|
||||
const deletedSurvey = await deleteSurvey(surveyId);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
|
||||
expect(deleteMock).toHaveBeenCalledWith({
|
||||
where: { id: surveyId },
|
||||
include: {
|
||||
segment: true,
|
||||
triggers: { include: { actionClass: true } },
|
||||
},
|
||||
});
|
||||
expect(segmentDeleteMock).not.toHaveBeenCalled();
|
||||
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
|
||||
});
|
||||
|
||||
test("should delete a private segment for app surveys", async () => {
|
||||
const deleteMock = vi.fn().mockResolvedValue(mockDeletedSurveyAppPrivateSegment);
|
||||
const segmentDeleteMock = vi.fn().mockResolvedValue({ id: segmentId });
|
||||
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
|
||||
callback({
|
||||
survey: { delete: deleteMock },
|
||||
segment: { delete: segmentDeleteMock },
|
||||
} as never)
|
||||
);
|
||||
|
||||
const deletedSurvey = await deleteSurvey(surveyId);
|
||||
|
||||
expect(segmentDeleteMock).toHaveBeenCalledWith({ where: { id: segmentId } });
|
||||
expect(deletedSurvey).toEqual(mockDeletedSurveyAppPrivateSegment);
|
||||
});
|
||||
|
||||
test("should map Prisma P2025 during survey deletion to ResourceNotFoundError", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "4.0.0",
|
||||
});
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(deleteSurvey(surveyId)).rejects.toThrow(ResourceNotFoundError);
|
||||
expect(logger.warn).toHaveBeenCalledWith({ surveyId }, "Survey not found during delete");
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle non-P2025 PrismaClientKnownRequestError during survey deletion", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Constraint failed", {
|
||||
code: "P2003",
|
||||
clientVersion: "4.0.0",
|
||||
});
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
|
||||
});
|
||||
|
||||
test("should handle generic errors during deletion", async () => {
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(genericError);
|
||||
|
||||
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw validation error for invalid surveyId", async () => {
|
||||
const invalidSurveyId = "invalid-id";
|
||||
const validationError = new Error("Validation failed");
|
||||
vi.mocked(validateInputs).mockImplementation(() => {
|
||||
throw validationError;
|
||||
});
|
||||
|
||||
await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const deleteSurvey = async (surveyId: string) => {
|
||||
validateInputs([surveyId, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const deletedSurvey = await tx.survey.delete({
|
||||
where: {
|
||||
id: surveyId,
|
||||
},
|
||||
include: {
|
||||
segment: true,
|
||||
triggers: {
|
||||
include: {
|
||||
actionClass: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
|
||||
await tx.segment.delete({
|
||||
where: {
|
||||
id: deletedSurvey.segment.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return deletedSurvey;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2025") {
|
||||
logger.warn({ surveyId }, "Survey not found during delete");
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
logger.error({ error, surveyId }, "Error deleting survey");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -2,52 +2,18 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZSurveyFilterCriteria } from "@formbricks/types/surveys/types";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import {
|
||||
getEnvironmentIdFromSurveyId,
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromSurveyId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
getProjectIdFromSurveyId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
|
||||
import { getUserProjects } from "@/modules/survey/list/lib/project";
|
||||
import {
|
||||
copySurveyToOtherEnvironment,
|
||||
deleteSurvey,
|
||||
getSurvey,
|
||||
getSurveys,
|
||||
} from "@/modules/survey/list/lib/survey";
|
||||
|
||||
const ZGetSurveyAction = z.object({
|
||||
surveyId: z.cuid2(),
|
||||
});
|
||||
|
||||
export const getSurveyAction = authenticatedActionClient
|
||||
.inputSchema(ZGetSurveyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getSurvey(parsedInput.surveyId);
|
||||
});
|
||||
import { copySurveyToOtherEnvironment } from "@/modules/survey/list/lib/survey";
|
||||
|
||||
const ZCopySurveyToOtherEnvironmentAction = z.object({
|
||||
surveyId: z.cuid2(),
|
||||
@@ -127,62 +93,6 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient
|
||||
})
|
||||
);
|
||||
|
||||
const ZGetProjectsByEnvironmentIdAction = z.object({
|
||||
environmentId: z.cuid2(),
|
||||
});
|
||||
|
||||
export const getProjectsByEnvironmentIdAction = authenticatedActionClient
|
||||
.inputSchema(ZGetProjectsByEnvironmentIdAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getUserProjects(ctx.user.id, organizationId);
|
||||
});
|
||||
|
||||
const ZDeleteSurveyAction = z.object({
|
||||
surveyId: z.cuid2(),
|
||||
});
|
||||
|
||||
export const deleteSurveyAction = authenticatedActionClient.inputSchema(ZDeleteSurveyAction).action(
|
||||
withAuditLogging("deleted", "survey", async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = await getSurvey(parsedInput.surveyId);
|
||||
return await deleteSurvey(parsedInput.surveyId);
|
||||
})
|
||||
);
|
||||
|
||||
const ZGenerateSingleUseIdAction = z.object({
|
||||
surveyId: z.cuid2(),
|
||||
isEncrypted: z.boolean(),
|
||||
@@ -210,39 +120,3 @@ export const generateSingleUseIdsAction = authenticatedActionClient
|
||||
|
||||
return generateSurveySingleUseIds(parsedInput.count, parsedInput.isEncrypted);
|
||||
});
|
||||
|
||||
const ZGetSurveysAction = z.object({
|
||||
environmentId: z.cuid2(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
filterCriteria: ZSurveyFilterCriteria.optional(),
|
||||
});
|
||||
|
||||
export const getSurveysAction = authenticatedActionClient
|
||||
.inputSchema(ZGetSurveysAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.filterCriteria,
|
||||
schema: ZSurveyFilterCriteria,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getSurveys(
|
||||
parsedInput.environmentId,
|
||||
parsedInput.limit,
|
||||
parsedInput.offset,
|
||||
parsedInput.filterCriteria
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||
import { TUserProject } from "@/modules/survey/list/types/projects";
|
||||
import { TSurvey, TSurveyCopyFormData, ZSurveyCopyFormValidation } from "@/modules/survey/list/types/surveys";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { FormControl, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface CopySurveyFormProps {
|
||||
readonly defaultProjects: TUserProject[];
|
||||
readonly survey: TSurvey;
|
||||
readonly onCancel: () => void;
|
||||
readonly setOpen: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface EnvironmentCheckboxProps {
|
||||
readonly environmentId: string;
|
||||
readonly environmentType: string;
|
||||
readonly fieldValue: string[];
|
||||
readonly onChange: (value: string[]) => void;
|
||||
}
|
||||
|
||||
function EnvironmentCheckbox({
|
||||
environmentId,
|
||||
environmentType,
|
||||
fieldValue,
|
||||
onChange,
|
||||
}: EnvironmentCheckboxProps) {
|
||||
const handleCheckedChange = () => {
|
||||
if (fieldValue.includes(environmentId)) {
|
||||
onChange(fieldValue.filter((id) => id !== environmentId));
|
||||
} else {
|
||||
onChange([...fieldValue, environmentId]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<div className="flex items-center">
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
checked={fieldValue.includes(environmentId)}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
|
||||
id={environmentId}
|
||||
/>
|
||||
<Label htmlFor={environmentId}>
|
||||
<p className="text-sm font-medium capitalize text-slate-900">{environmentType}</p>
|
||||
</Label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface EnvironmentCheckboxGroupProps {
|
||||
readonly project: TUserProject;
|
||||
readonly form: ReturnType<typeof useForm<TSurveyCopyFormData>>;
|
||||
readonly projectIndex: number;
|
||||
}
|
||||
|
||||
function EnvironmentCheckboxGroup({ project, form, projectIndex }: EnvironmentCheckboxGroupProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{project.environments.map((environment) => (
|
||||
<FormField
|
||||
key={environment.id}
|
||||
control={form.control}
|
||||
name={`projects.${projectIndex}.environments`}
|
||||
render={({ field }) => (
|
||||
<EnvironmentCheckbox
|
||||
environmentId={environment.id}
|
||||
environmentType={environment.type}
|
||||
fieldValue={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: CopySurveyFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const filteredProjects = defaultProjects.map((project) => ({
|
||||
...project,
|
||||
environments: project.environments.filter((env) => env.id !== survey.environmentId),
|
||||
}));
|
||||
|
||||
const form = useForm<TSurveyCopyFormData>({
|
||||
resolver: zodResolver(ZSurveyCopyFormValidation),
|
||||
defaultValues: {
|
||||
projects: filteredProjects.map((project) => ({
|
||||
project: project.id,
|
||||
environments: [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const formFields = useFieldArray({
|
||||
name: "projects",
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
async function onSubmit(data: TSurveyCopyFormData) {
|
||||
const filteredData = data.projects.filter((project) => project.environments.length > 0);
|
||||
|
||||
try {
|
||||
const copyOperationsWithMetadata = filteredData.flatMap((projectData) => {
|
||||
const project = filteredProjects.find((p) => p.id === projectData.project);
|
||||
return projectData.environments.map((environmentId) => {
|
||||
const environment =
|
||||
project?.environments[0]?.id === environmentId
|
||||
? project?.environments[0]
|
||||
: project?.environments[1];
|
||||
|
||||
return {
|
||||
projectName: project?.name ?? "Unknown Project",
|
||||
environmentType: environment?.type ?? "unknown",
|
||||
environmentId,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const results: Awaited<ReturnType<typeof copySurveyToOtherEnvironmentAction>>[] = [];
|
||||
for (const item of copyOperationsWithMetadata) {
|
||||
const result = await copySurveyToOtherEnvironmentAction({
|
||||
surveyId: survey.id,
|
||||
targetEnvironmentId: item.environmentId,
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
const errorsIndexes: number[] = [];
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result?.data) {
|
||||
successCount++;
|
||||
} else {
|
||||
errorsIndexes.push(index);
|
||||
errorCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (successCount > 0) {
|
||||
if (errorCount === 0) {
|
||||
toast.success(t("environments.surveys.copy_survey_success"));
|
||||
} else {
|
||||
toast.error(
|
||||
t("environments.surveys.copy_survey_partially_success", {
|
||||
success: successCount,
|
||||
error: errorCount,
|
||||
}),
|
||||
{
|
||||
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errorsIndexes.length > 0) {
|
||||
errorsIndexes.forEach((index, idx) => {
|
||||
const { projectName, environmentType } = copyOperationsWithMetadata[index];
|
||||
const result = results[index];
|
||||
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(`[${projectName}] - [${environmentType}] - ${errorMessage}`, {
|
||||
duration: 2000 + 2000 * idx,
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.copy_survey_error"));
|
||||
} finally {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex h-full w-full flex-col bg-white">
|
||||
<div className="flex-1 space-y-8 overflow-y-auto">
|
||||
{formFields.fields.map((field, projectIndex) => {
|
||||
const project = filteredProjects.find((project) => project.id === field.project);
|
||||
if (!project) return null;
|
||||
|
||||
return (
|
||||
<div key={project.id}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-fit">
|
||||
<p className="text-base font-semibold text-slate-900">{project.name}</p>
|
||||
</div>
|
||||
<EnvironmentCheckboxGroup project={project} form={form} projectIndex={projectIndex} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="sticky bottom-0 flex justify-end space-x-2 bg-white pt-4">
|
||||
<Button type="button" onClick={onCancel} variant="secondary">
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("environments.surveys.copy_survey")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MousePointerClickIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import SurveyCopyOptions from "./survey-copy-options";
|
||||
|
||||
interface CopySurveyModalProps {
|
||||
open: boolean;
|
||||
setOpen: (value: boolean) => void;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const CopySurveyModal = ({ open, setOpen, survey }: CopySurveyModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[600px]">
|
||||
<DialogHeader>
|
||||
<MousePointerClickIcon />
|
||||
<DialogTitle>{t("environments.surveys.copy_survey")}</DialogTitle>
|
||||
<DialogDescription>{t("environments.surveys.copy_survey_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<SurveyCopyOptions
|
||||
survey={survey}
|
||||
environmentId={survey.environmentId}
|
||||
onCancel={() => setOpen(false)}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
|
||||
import { TSortOption } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
|
||||
import { DropdownMenuItem } from "@/modules/ui/components/dropdown-menu";
|
||||
|
||||
interface SortOptionProps {
|
||||
option: TSortOption;
|
||||
sortBy: TSurveyFilters["sortBy"];
|
||||
sortBy: TSurveyOverviewFilters["sortBy"];
|
||||
handleSortChange: (option: TSortOption) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,27 +8,25 @@ import { cn } from "@/lib/cn";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { formatDateForDisplay } from "@/lib/utils/datetime";
|
||||
import { SurveyTypeIndicator } from "@/modules/survey/list/components/survey-type-indicator";
|
||||
import { TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
import { TSurveyListItem } from "@/modules/survey/list/types/survey-overview";
|
||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||
import { SurveyDropDownMenu } from "./survey-dropdown-menu";
|
||||
|
||||
interface SurveyCardProps {
|
||||
survey: TSurvey;
|
||||
survey: TSurveyListItem;
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
publicDomain: string;
|
||||
deleteSurvey: (surveyId: string) => void;
|
||||
isReadOnly: boolean;
|
||||
deleteSurvey: (surveyId: string) => Promise<void>;
|
||||
locale: TUserLocale;
|
||||
onSurveysCopied?: () => void;
|
||||
}
|
||||
export const SurveyCard = ({
|
||||
survey,
|
||||
environmentId,
|
||||
isReadOnly,
|
||||
publicDomain,
|
||||
isReadOnly,
|
||||
deleteSurvey,
|
||||
locale,
|
||||
onSurveysCopied,
|
||||
}: SurveyCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const surveyStatusLabel = (() => {
|
||||
@@ -56,43 +54,53 @@ export const SurveyCard = ({
|
||||
|
||||
const isDraftAndReadOnly = survey.status === "draft" && isReadOnly;
|
||||
|
||||
const CardContent = (
|
||||
<>
|
||||
const CardBody = (
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 pr-8 shadow-sm transition-colors ease-in-out",
|
||||
!isDraftAndReadOnly && "hover:border-slate-400"
|
||||
)}>
|
||||
<div className="col-span-2 flex max-w-full items-center justify-self-start text-sm font-medium text-slate-900">
|
||||
<div className="w-full truncate">{survey.name}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 pr-8 shadow-sm transition-colors ease-in-out",
|
||||
!isDraftAndReadOnly && "hover:border-slate-400"
|
||||
"col-span-1 flex w-fit items-center gap-2 whitespace-nowrap rounded-full py-1 pl-1 pr-2 text-sm text-slate-800",
|
||||
survey.status === "inProgress" && "bg-emerald-50",
|
||||
survey.status === "completed" && "bg-slate-200",
|
||||
survey.status === "draft" && "bg-slate-100",
|
||||
survey.status === "paused" && "bg-slate-100"
|
||||
)}>
|
||||
<div className="col-span-2 flex max-w-full items-center justify-self-start text-sm font-medium text-slate-900">
|
||||
<div className="w-full truncate">{survey.name}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"col-span-1 flex w-fit items-center gap-2 whitespace-nowrap rounded-full py-1 pl-1 pr-2 text-sm text-slate-800",
|
||||
surveyStatusLabel === "In Progress" && "bg-emerald-50",
|
||||
surveyStatusLabel === "Completed" && "bg-slate-200",
|
||||
surveyStatusLabel === "Draft" && "bg-slate-100",
|
||||
surveyStatusLabel === "Paused" && "bg-slate-100"
|
||||
)}>
|
||||
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}{" "}
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{survey.responseCount}
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-between">
|
||||
<SurveyTypeIndicator type={survey.type} />
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{formatDateForDisplay(survey.createdAt, locale)}
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{timeSince(survey.updatedAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{survey.creator ? survey.creator.name : "-"}
|
||||
</div>
|
||||
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}{" "}
|
||||
</div>
|
||||
<button className="absolute right-3 top-3.5" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{survey.responseCount}
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-between">
|
||||
<SurveyTypeIndicator type={survey.type} />
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{formatDateForDisplay(survey.createdAt, locale)}
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{timeSince(survey.updatedAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{survey.creator ? survey.creator.name : "-"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative block">
|
||||
{isDraftAndReadOnly ? (
|
||||
CardBody
|
||||
) : (
|
||||
<Link href={linkHref} key={survey.id} className="block">
|
||||
{CardBody}
|
||||
</Link>
|
||||
)}
|
||||
<div className="absolute right-3 top-3.5">
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
@@ -101,17 +109,8 @@ export const SurveyCard = ({
|
||||
disabled={isDraftAndReadOnly}
|
||||
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
|
||||
deleteSurvey={deleteSurvey}
|
||||
onSurveysCopied={onSurveysCopied}
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return isDraftAndReadOnly ? (
|
||||
<div className="relative block">{CardContent}</div>
|
||||
) : (
|
||||
<Link href={linkHref} key={survey.id} className="relative block">
|
||||
{CardContent}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { getProjectsByEnvironmentIdAction } from "@/modules/survey/list/actions";
|
||||
import { TUserProject } from "@/modules/survey/list/types/projects";
|
||||
import { TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
import { CopySurveyForm } from "./copy-survey-form";
|
||||
|
||||
interface SurveyCopyOptionsProps {
|
||||
survey: TSurvey;
|
||||
environmentId: string;
|
||||
onCancel: () => void;
|
||||
setOpen: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const SurveyCopyOptions = ({ environmentId, survey, onCancel, setOpen }: SurveyCopyOptionsProps) => {
|
||||
const [projects, setProjects] = useState<TUserProject[]>([]);
|
||||
const [projectLoading, setProjectLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjects = async () => {
|
||||
const getProjectsByEnvironmentIdResponse = await getProjectsByEnvironmentIdAction({ environmentId });
|
||||
if (getProjectsByEnvironmentIdResponse?.data) {
|
||||
setProjects(getProjectsByEnvironmentIdResponse?.data);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(getProjectsByEnvironmentIdResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
setProjectLoading(false);
|
||||
};
|
||||
|
||||
fetchProjects();
|
||||
}, [environmentId]);
|
||||
|
||||
if (projectLoading) {
|
||||
return (
|
||||
<div className="relative flex h-full min-h-96 w-full items-center justify-center bg-white pb-12">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <CopySurveyForm defaultProjects={projects} survey={survey} onCancel={onCancel} setOpen={setOpen} />;
|
||||
};
|
||||
|
||||
export default SurveyCopyOptions;
|
||||
@@ -1,14 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowUpFromLineIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
LinkIcon,
|
||||
MoreVertical,
|
||||
SquarePenIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { EyeIcon, LinkIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -16,15 +8,10 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
|
||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
||||
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
|
||||
import {
|
||||
copySurveyToOtherEnvironmentAction,
|
||||
deleteSurveyAction,
|
||||
getSurveyAction,
|
||||
} from "@/modules/survey/list/actions";
|
||||
import { TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
import { TSurveyListItem } from "@/modules/survey/list/types/survey-overview";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -33,16 +20,14 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { CopySurveyModal } from "./copy-survey-modal";
|
||||
|
||||
interface SurveyDropDownMenuProps {
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
survey: TSurveyListItem;
|
||||
publicDomain: string;
|
||||
disabled?: boolean;
|
||||
isSurveyCreationDeletionDisabled?: boolean;
|
||||
deleteSurvey: (surveyId: string) => void;
|
||||
onSurveysCopied?: () => void;
|
||||
deleteSurvey: (surveyId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SurveyDropDownMenu = ({
|
||||
@@ -52,32 +37,29 @@ export const SurveyDropDownMenu = ({
|
||||
disabled,
|
||||
isSurveyCreationDeletionDisabled,
|
||||
deleteSurvey,
|
||||
onSurveysCopied,
|
||||
}: SurveyDropDownMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
|
||||
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const surveyLink = useMemo(() => publicDomain + "/s/" + survey.id, [survey.id, publicDomain]);
|
||||
const editHref = `/environments/${environmentId}/surveys/${survey.id}/edit`;
|
||||
const surveyLink = useMemo(() => `${publicDomain}/s/${survey.id}`, [publicDomain, survey.id]);
|
||||
const isSingleUseEnabled = survey.singleUse?.enabled ?? false;
|
||||
const canManageSurvey = !isSurveyCreationDeletionDisabled;
|
||||
const canPreviewOrCopyLink = survey.type === "link" && survey.status !== "draft";
|
||||
const hasVisibleActions = canManageSurvey || canPreviewOrCopyLink;
|
||||
|
||||
const handleDeleteSurvey = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await deleteSurveyAction({ surveyId });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
deleteSurvey(surveyId);
|
||||
await deleteSurvey(surveyId);
|
||||
toast.success(t("environments.surveys.survey_deleted_successfully"));
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.error_deleting_survey"));
|
||||
toast.error(getV3ApiErrorMessage(error, t("environments.surveys.error_deleting_survey")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -87,147 +69,93 @@ export const SurveyDropDownMenu = ({
|
||||
try {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
// For single-use surveys, this button is disabled, so we just copy the base link
|
||||
const copiedLink = copySurveyLink(surveyLink);
|
||||
navigator.clipboard.writeText(copiedLink);
|
||||
await navigator.clipboard.writeText(copySurveyLink(surveyLink));
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
toast.error(t("environments.surveys.summary.failed_to_copy_link"));
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
};
|
||||
|
||||
const duplicateSurveyAndRefresh = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
|
||||
surveyId,
|
||||
targetEnvironmentId: environmentId,
|
||||
});
|
||||
|
||||
if (duplicatedSurveyResponse?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(duplicatedSurveyResponse));
|
||||
} else if (duplicatedSurveyResponse?.data) {
|
||||
const transformedDuplicatedSurvey = await getSurveyAction({
|
||||
surveyId: duplicatedSurveyResponse.data.id,
|
||||
});
|
||||
if (transformedDuplicatedSurvey?.data) {
|
||||
onSurveysCopied?.();
|
||||
}
|
||||
toast.success(t("environments.surveys.survey_duplicated_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(duplicatedSurveyResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.survey_duplication_error"));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleEditforActiveSurvey = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setIsCautionDialogOpen(true);
|
||||
};
|
||||
|
||||
if (!hasVisibleActions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
|
||||
data-testid="survey-dropdown-menu">
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
data-testid="survey-dropdown-trigger"
|
||||
aria-label={t("environments.surveys.open_options")}
|
||||
className={cn(
|
||||
"rounded-lg border bg-white p-2",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-slate-50"
|
||||
)}>
|
||||
<span className="sr-only">{t("environments.surveys.open_options")}</span>
|
||||
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="inline-block w-auto min-w-max">
|
||||
<DropdownMenuGroup>
|
||||
{!isSurveyCreationDeletionDisabled && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}
|
||||
onClick={survey.responseCount > 0 ? handleEditforActiveSurvey : undefined}>
|
||||
<SquarePenIcon className="mr-2 size-4" />
|
||||
{t("common.edit")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
duplicateSurveyAndRefresh(survey.id);
|
||||
}}>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.duplicate")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
{canManageSurvey && (
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={editHref}
|
||||
onClick={survey.responseCount > 0 ? handleEditforActiveSurvey : undefined}>
|
||||
<SquarePenIcon className="mr-2 size-4" />
|
||||
{t("common.edit")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!isSurveyCreationDeletionDisabled && (
|
||||
{canPreviewOrCopyLink && (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"flex w-full items-center",
|
||||
isSingleUseEnabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
disabled={isSingleUseEnabled}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setIsCopyFormOpen(true);
|
||||
const previewUrl = new URL(surveyLink);
|
||||
previewUrl.searchParams.set("preview", "true");
|
||||
globalThis.window.open(previewUrl.toString(), "_blank");
|
||||
}}>
|
||||
<ArrowUpFromLineIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.copy")}...
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.preview")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{survey.type === "link" && survey.status !== "draft" && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center",
|
||||
isSingleUseEnabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
disabled={isSingleUseEnabled}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
const previewUrl = surveyLink + "?preview=true";
|
||||
window.open(previewUrl, "_blank");
|
||||
}}>
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.preview_survey")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="copy-link"
|
||||
className={cn(
|
||||
"flex w-full items-center",
|
||||
isSingleUseEnabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
disabled={isSingleUseEnabled}
|
||||
onClick={async (e) => handleCopyLink(e)}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.copy_link")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
{canPreviewOrCopyLink && (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="copy-link"
|
||||
className={cn(
|
||||
"flex w-full items-center",
|
||||
isSingleUseEnabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
disabled={isSingleUseEnabled}
|
||||
onClick={handleCopyLink}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.copy_link")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!isSurveyCreationDeletionDisabled && (
|
||||
{canManageSurvey && (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
@@ -246,7 +174,7 @@ export const SurveyDropDownMenu = ({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{!isSurveyCreationDeletionDisabled && (
|
||||
{canManageSurvey && (
|
||||
<DeleteDialog
|
||||
deleteWhat={t("common.survey")}
|
||||
open={isDeleteDialogOpen}
|
||||
@@ -257,26 +185,20 @@ export const SurveyDropDownMenu = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{survey.responseCount > 0 && (
|
||||
{canManageSurvey && survey.responseCount > 0 && (
|
||||
<EditPublicSurveyAlertDialog
|
||||
open={isCautionDialogOpen}
|
||||
setOpen={setIsCautionDialogOpen}
|
||||
isLoading={loading}
|
||||
primaryButtonAction={async () => {
|
||||
await duplicateSurveyAndRefresh(survey.id);
|
||||
setIsCautionDialogOpen(false);
|
||||
router.push(editHref);
|
||||
}}
|
||||
primaryButtonText={t("common.duplicate")}
|
||||
secondaryButtonAction={() =>
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`)
|
||||
}
|
||||
secondaryButtonText={t("common.edit")}
|
||||
primaryButtonText={t("common.edit")}
|
||||
secondaryButtonAction={() => setIsCautionDialogOpen(false)}
|
||||
secondaryButtonText={t("common.cancel")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isCopyFormOpen && (
|
||||
<CopySurveyModal open={isCopyFormOpen} setOpen={setIsCopyFormOpen} survey={survey} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
interface SurveyFilterDropdownProps {
|
||||
title: string;
|
||||
id: "createdBy" | "status" | "type";
|
||||
id: "status" | "type";
|
||||
options: TFilterOption[];
|
||||
selectedOptions: string[];
|
||||
setSelectedOptions: (value: string) => void;
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { TFunction } from "i18next";
|
||||
import { ChevronDownIcon, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebounce } from "react-use";
|
||||
import { TProjectConfigChannel } from "@formbricks/types/project";
|
||||
import { TFilterOption, TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
|
||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||
import type { TProjectConfigChannel } from "@formbricks/types/project";
|
||||
import type { TFilterOption, TSortOption } from "@formbricks/types/surveys/types";
|
||||
import { SortOption } from "@/modules/survey/list/components/sort-option";
|
||||
import { initialFilters } from "@/modules/survey/list/lib/constants";
|
||||
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -20,16 +20,11 @@ import { SearchBar } from "@/modules/ui/components/search-bar";
|
||||
import { SurveyFilterDropdown } from "./survey-filter-dropdown";
|
||||
|
||||
interface SurveyFilterProps {
|
||||
surveyFilters: TSurveyFilters;
|
||||
setSurveyFilters: React.Dispatch<React.SetStateAction<TSurveyFilters>>;
|
||||
surveyFilters: TSurveyOverviewFilters;
|
||||
setSurveyFilters: Dispatch<SetStateAction<TSurveyOverviewFilters>>;
|
||||
currentProjectChannel: TProjectConfigChannel;
|
||||
}
|
||||
|
||||
const getCreatorOptions = (t: TFunction): TFilterOption[] => [
|
||||
{ label: t("common.you"), value: "you" },
|
||||
{ label: t("common.others"), value: "others" },
|
||||
];
|
||||
|
||||
const getStatusOptions = (t: TFunction): TFilterOption[] => [
|
||||
{ label: t("common.draft"), value: "draft" },
|
||||
{ label: t("common.in_progress"), value: "inProgress" },
|
||||
@@ -61,10 +56,10 @@ export const SurveyFilters = ({
|
||||
setSurveyFilters,
|
||||
currentProjectChannel,
|
||||
}: SurveyFilterProps) => {
|
||||
const { createdBy, sortBy, status, type } = surveyFilters;
|
||||
const [name, setName] = useState("");
|
||||
const { sortBy, status, type } = surveyFilters;
|
||||
const [name, setName] = useState(surveyFilters.name);
|
||||
const { t } = useTranslation();
|
||||
useDebounce(() => setSurveyFilters((prev) => ({ ...prev, name: name })), 800, [name]);
|
||||
useDebounce(() => setSurveyFilters((prev) => ({ ...prev, name })), 800, [name]);
|
||||
|
||||
const [dropdownOpenStates, setDropdownOpenStates] = useState(new Map());
|
||||
|
||||
@@ -73,20 +68,14 @@ export const SurveyFilters = ({
|
||||
{ label: t("common.app"), value: "app" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
setName(surveyFilters.name);
|
||||
}, [surveyFilters.name]);
|
||||
|
||||
const toggleDropdown = (id: string) => {
|
||||
setDropdownOpenStates(new Map(dropdownOpenStates).set(id, !dropdownOpenStates.get(id)));
|
||||
};
|
||||
|
||||
const handleCreatedByChange = (value: string) => {
|
||||
if (value === "you" || value === "others") {
|
||||
if (createdBy.includes(value)) {
|
||||
setSurveyFilters((prev) => ({ ...prev, createdBy: prev.createdBy.filter((v) => v !== value) }));
|
||||
} else {
|
||||
setSurveyFilters((prev) => ({ ...prev, createdBy: [...prev.createdBy, value] }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = (value: string) => {
|
||||
if (value === "inProgress" || value === "paused" || value === "completed" || value === "draft") {
|
||||
if (status.includes(value)) {
|
||||
@@ -120,17 +109,6 @@ export const SurveyFilters = ({
|
||||
placeholder={t("environments.surveys.search_by_survey_name")}
|
||||
className="border-slate-700"
|
||||
/>
|
||||
<div>
|
||||
<SurveyFilterDropdown
|
||||
title={t("common.created_by")}
|
||||
id="createdBy"
|
||||
options={getCreatorOptions(t)}
|
||||
selectedOptions={createdBy}
|
||||
setSelectedOptions={handleCreatedByChange}
|
||||
isOpen={dropdownOpenStates.get("createdBy")}
|
||||
toggleDropdown={toggleDropdown}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SurveyFilterDropdown
|
||||
title={t("common.status")}
|
||||
@@ -156,13 +134,12 @@ export const SurveyFilters = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(createdBy.length > 0 || status.length > 0 || type.length > 0 || name) && (
|
||||
{(status.length > 0 || type.length > 0 || name) && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSurveyFilters(initialFilters);
|
||||
setName(""); // Also clear the search input
|
||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
setName(initialFilters.name);
|
||||
}}
|
||||
className="h-8">
|
||||
{t("common.clear_filters")}
|
||||
|
||||
@@ -1,195 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type ComponentProps, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { wrapThrows } from "@formbricks/types/error-handlers";
|
||||
import { TProjectConfigChannel } from "@formbricks/types/project";
|
||||
import { TSurveyFilters } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||
import { getSurveysAction } from "@/modules/survey/list/actions";
|
||||
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
|
||||
import { useDeleteSurvey } from "@/modules/survey/list/hooks/use-delete-survey";
|
||||
import { useSurveys } from "@/modules/survey/list/hooks/use-surveys";
|
||||
import { initialFilters } from "@/modules/survey/list/lib/constants";
|
||||
import { getFormattedFilters } from "@/modules/survey/list/lib/utils";
|
||||
import { TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
import {
|
||||
hasActiveSurveyFilters,
|
||||
normalizeSurveyFilters,
|
||||
parseStoredSurveyFilters,
|
||||
} from "@/modules/survey/list/lib/utils";
|
||||
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
|
||||
import { TemplateContainerWithPreview } from "@/modules/survey/templates/components/template-container";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SurveyCard } from "./survey-card";
|
||||
import { SurveyFilters } from "./survey-filters";
|
||||
import { SurveyLoading } from "./survey-loading";
|
||||
|
||||
interface SurveysListProps {
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
publicDomain: string;
|
||||
environment: ComponentProps<typeof TemplateContainerWithPreview>["environment"];
|
||||
project: ComponentProps<typeof TemplateContainerWithPreview>["project"];
|
||||
userId: string;
|
||||
publicDomain: string;
|
||||
isReadOnly: boolean;
|
||||
surveysPerPage: number;
|
||||
currentProjectChannel: TProjectConfigChannel;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const SurveysList = ({
|
||||
environmentId,
|
||||
isReadOnly,
|
||||
publicDomain,
|
||||
environment,
|
||||
project,
|
||||
userId,
|
||||
surveysPerPage: surveysLimit,
|
||||
publicDomain,
|
||||
isReadOnly,
|
||||
surveysPerPage,
|
||||
currentProjectChannel,
|
||||
locale,
|
||||
}: SurveysListProps) => {
|
||||
const router = useRouter();
|
||||
const [surveys, setSurveys] = useState<TSurvey[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const [surveyFilters, setSurveyFilters] = useState<TSurveyFilters>(initialFilters);
|
||||
const [surveyFilters, setSurveyFilters] = useState<TSurveyOverviewFilters>(initialFilters);
|
||||
const [isFilterInitialized, setIsFilterInitialized] = useState(false);
|
||||
|
||||
const { name, createdBy, status, type, sortBy } = surveyFilters;
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(surveyFilters, userId),
|
||||
[name, JSON.stringify(createdBy), JSON.stringify(status), JSON.stringify(type), sortBy, userId]
|
||||
);
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const savedFilters = localStorage.getItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
if (savedFilters) {
|
||||
const surveyParseResult = wrapThrows(() => JSON.parse(savedFilters))();
|
||||
|
||||
if (!surveyParseResult.ok) {
|
||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
setSurveyFilters(initialFilters);
|
||||
} else {
|
||||
setSurveyFilters(surveyParseResult.data);
|
||||
}
|
||||
}
|
||||
setIsFilterInitialized(true);
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const storedFilters = globalThis.window.localStorage.getItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
const parsedFilters = parseStoredSurveyFilters(storedFilters, currentProjectChannel);
|
||||
|
||||
if (storedFilters && !parsedFilters) {
|
||||
globalThis.window.localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
setSurveyFilters(initialFilters);
|
||||
} else if (parsedFilters) {
|
||||
setSurveyFilters(parsedFilters);
|
||||
}
|
||||
|
||||
setIsFilterInitialized(true);
|
||||
}, [currentProjectChannel]);
|
||||
|
||||
const normalizedFilters = useMemo(
|
||||
() => normalizeSurveyFilters(surveyFilters, currentProjectChannel),
|
||||
[currentProjectChannel, surveyFilters]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFilterInitialized) {
|
||||
localStorage.setItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS, JSON.stringify(surveyFilters));
|
||||
if (!isFilterInitialized || typeof globalThis.window === "undefined") {
|
||||
return;
|
||||
}
|
||||
}, [surveyFilters, isFilterInitialized]);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for filters to be loaded from localStorage before fetching
|
||||
if (!isFilterInitialized) return;
|
||||
globalThis.window.localStorage.setItem(
|
||||
FORMBRICKS_SURVEYS_FILTERS_KEY_LS,
|
||||
JSON.stringify(normalizedFilters)
|
||||
);
|
||||
}, [normalizedFilters, isFilterInitialized]);
|
||||
|
||||
const fetchFilteredSurveys = async () => {
|
||||
setIsFetching(true);
|
||||
const res = await getSurveysAction({
|
||||
environmentId,
|
||||
limit: surveysLimit,
|
||||
offset: undefined,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
if (res?.data) {
|
||||
if (res.data.length < surveysLimit) {
|
||||
setHasMore(false);
|
||||
} else {
|
||||
setHasMore(true);
|
||||
}
|
||||
setSurveys(res.data);
|
||||
setIsFetching(false);
|
||||
}
|
||||
};
|
||||
fetchFilteredSurveys();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [environmentId, surveysLimit, filters, refreshTrigger, isFilterInitialized]);
|
||||
const {
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isError,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
queryKey,
|
||||
refetch,
|
||||
surveys,
|
||||
totalCount,
|
||||
} = useSurveys({
|
||||
workspaceId: environment.id,
|
||||
limit: surveysPerPage,
|
||||
filters: normalizedFilters,
|
||||
enabled: isFilterInitialized,
|
||||
});
|
||||
|
||||
const fetchNextPage = useCallback(async () => {
|
||||
setIsFetching(true);
|
||||
const res = await getSurveysAction({
|
||||
environmentId,
|
||||
limit: surveysLimit,
|
||||
offset: surveys.length,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
if (res?.data) {
|
||||
if (res.data.length === 0 || res.data.length < surveysLimit) {
|
||||
setHasMore(false);
|
||||
} else {
|
||||
setHasMore(true);
|
||||
}
|
||||
const deleteSurveyMutation = useDeleteSurvey({ queryKey });
|
||||
|
||||
setSurveys([...surveys, ...res.data]);
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [environmentId, surveys, surveysLimit, filters]);
|
||||
const hasAppliedFilters = hasActiveSurveyFilters(normalizedFilters);
|
||||
const showInitialLoading = !isFilterInitialized || (isLoading && surveys.length === 0);
|
||||
const showTemplateEmptyState = !isError && totalCount === 0 && !hasAppliedFilters && !isReadOnly;
|
||||
const showReadOnlyEmptyState = !isError && totalCount === 0 && !hasAppliedFilters && isReadOnly;
|
||||
|
||||
const handleDeleteSurvey = async (surveyId: string) => {
|
||||
const newSurveys = surveys.filter((survey) => survey.id !== surveyId);
|
||||
setSurveys(newSurveys);
|
||||
if (newSurveys.length === 0) {
|
||||
setIsFetching(true);
|
||||
router.refresh();
|
||||
}
|
||||
await deleteSurveyMutation.mutateAsync({ surveyId });
|
||||
};
|
||||
|
||||
const triggerRefresh = useCallback(() => {
|
||||
setRefreshTrigger((prev) => !prev);
|
||||
}, []);
|
||||
const createSurveyButton = (
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys/templates`}>
|
||||
{t("environments.surveys.new_survey")}
|
||||
<PlusIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SurveyFilters
|
||||
surveyFilters={surveyFilters}
|
||||
setSurveyFilters={setSurveyFilters}
|
||||
currentProjectChannel={currentProjectChannel}
|
||||
/>
|
||||
{surveys.length > 0 ? (
|
||||
<div>
|
||||
<div className="flex-col space-y-3" ref={parent}>
|
||||
<div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 pr-8 text-sm text-slate-800">
|
||||
<div className="col-span-2 place-self-start">{t("common.name")}</div>
|
||||
<div className="col-span-1">{t("common.status")}</div>
|
||||
<div className="col-span-1">{t("common.responses")}</div>
|
||||
<div className="col-span-1">{t("common.type")}</div>
|
||||
<div className="col-span-1">{t("common.created_at")}</div>
|
||||
<div className="col-span-1">{t("common.updated_at")}</div>
|
||||
<div className="col-span-1">{t("common.created_by")}</div>
|
||||
</div>
|
||||
{surveys.map((survey) => {
|
||||
return (
|
||||
<SurveyCard
|
||||
key={survey.id}
|
||||
survey={survey}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
publicDomain={publicDomain}
|
||||
deleteSurvey={handleDeleteSurvey}
|
||||
locale={locale}
|
||||
onSurveysCopied={triggerRefresh}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
if (showInitialLoading) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.surveys")} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex h-9 animate-pulse gap-2">
|
||||
<div className="w-48 rounded-md bg-slate-300"></div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="w-24 rounded-md bg-slate-300"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex h-9 animate-pulse gap-2">
|
||||
<div className="w-36 rounded-md bg-slate-300"></div>
|
||||
</div>
|
||||
</div>
|
||||
<SurveyLoading />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center py-5">
|
||||
<Button onClick={fetchNextPage} variant="secondary" size="sm" loading={isFetching}>
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full">
|
||||
{isFetching ? (
|
||||
<SurveyLoading />
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-center justify-center text-slate-600">
|
||||
<span className="h-24 w-24 p-4 text-center text-5xl">🕵️</span>
|
||||
{t("common.no_surveys_found")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
if (showTemplateEmptyState) {
|
||||
return (
|
||||
<TemplateContainerWithPreview
|
||||
userId={userId}
|
||||
environment={environment}
|
||||
project={project}
|
||||
isTemplatePage={false}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (showReadOnlyEmptyState) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<h1 className="px-6 text-3xl font-extrabold text-slate-700">
|
||||
{t("environments.surveys.no_surveys_created_yet")}
|
||||
</h1>
|
||||
<h2 className="px-6 text-lg font-medium text-slate-500">
|
||||
{t("environments.surveys.read_only_user_not_allowed_to_create_survey_warning")}
|
||||
</h2>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
let surveyContent = (
|
||||
<div className="flex h-full w-full">
|
||||
<div className="flex w-full flex-col items-center justify-center text-slate-600">
|
||||
<span className="h-24 w-24 p-4 text-center text-5xl">🕵️</span>
|
||||
{t("common.no_surveys_found")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isError && surveys.length === 0) {
|
||||
surveyContent = (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-4 py-16 text-slate-600">
|
||||
<p>{getV3ApiErrorMessage(error, t("common.something_went_wrong_please_try_again"))}</p>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetch()}>
|
||||
{t("common.try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
} else if (surveys.length > 0) {
|
||||
surveyContent = (
|
||||
<div>
|
||||
<div className="flex-col space-y-3" ref={parent}>
|
||||
<div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 pr-8 text-sm text-slate-800">
|
||||
<div className="col-span-2 place-self-start">{t("common.name")}</div>
|
||||
<div className="col-span-1">{t("common.status")}</div>
|
||||
<div className="col-span-1">{t("common.responses")}</div>
|
||||
<div className="col-span-1">{t("common.type")}</div>
|
||||
<div className="col-span-1">{t("common.created_at")}</div>
|
||||
<div className="col-span-1">{t("common.updated_at")}</div>
|
||||
<div className="col-span-1">{t("common.created_by")}</div>
|
||||
</div>
|
||||
{surveys.map((survey) => (
|
||||
<SurveyCard
|
||||
key={survey.id}
|
||||
survey={survey}
|
||||
environmentId={environment.id}
|
||||
isReadOnly={isReadOnly}
|
||||
deleteSurvey={handleDeleteSurvey}
|
||||
publicDomain={publicDomain}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center py-5">
|
||||
<Button
|
||||
onClick={() => fetchNextPage()}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={isFetchingNextPage}>
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : createSurveyButton} />
|
||||
<div className="space-y-6">
|
||||
<SurveyFilters
|
||||
surveyFilters={normalizedFilters}
|
||||
setSurveyFilters={setSurveyFilters}
|
||||
currentProjectChannel={currentProjectChannel}
|
||||
/>
|
||||
{surveyContent}
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { type ReactNode, createElement } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { surveyKeys } from "@/modules/survey/list/lib/query";
|
||||
import { TSurveyListPage } from "@/modules/survey/list/lib/v3-surveys-client";
|
||||
import { useDeleteSurvey } from "./use-delete-survey";
|
||||
|
||||
function createWrapper(queryClient: QueryClient) {
|
||||
const Wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
Wrapper.displayName = "UseDeleteSurveyTestWrapper";
|
||||
|
||||
return Wrapper;
|
||||
}
|
||||
|
||||
function createQueryData(): { pages: TSurveyListPage[]; pageParams: (string | null)[] } {
|
||||
return {
|
||||
pages: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
id: "survey_1",
|
||||
name: "Survey 1",
|
||||
workspaceId: "env_1",
|
||||
type: "link",
|
||||
status: "draft",
|
||||
createdAt: new Date("2026-04-15T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
|
||||
responseCount: 0,
|
||||
creator: { name: "Alice" },
|
||||
singleUse: null,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
limit: 20,
|
||||
nextCursor: null,
|
||||
totalCount: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
pageParams: [null],
|
||||
};
|
||||
}
|
||||
|
||||
describe("useDeleteSurvey", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("optimistically removes a survey and invalidates list queries on success", async () => {
|
||||
let resolveFetch: ((value: Response) => void) | undefined;
|
||||
const fetchPromise = new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(global.fetch).mockReturnValue(fetchPromise as Promise<Response>);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: { retry: false },
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
const queryKey = surveyKeys.list({
|
||||
workspaceId: "env_1",
|
||||
limit: 20,
|
||||
filters: {
|
||||
name: "",
|
||||
status: [],
|
||||
type: [],
|
||||
sortBy: "relevance",
|
||||
},
|
||||
});
|
||||
queryClient.setQueryData(queryKey, createQueryData());
|
||||
|
||||
const invalidateQueriesSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
|
||||
const { result } = renderHook(() => useDeleteSurvey({ queryKey }), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
result.current.mutate({ surveyId: "survey_1" });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(queryClient.getQueryData<{ pages: TSurveyListPage[] }>(queryKey)?.pages[0]?.data).toEqual([])
|
||||
);
|
||||
expect(queryClient.getQueryData<{ pages: TSurveyListPage[] }>(queryKey)?.pages[0]?.meta.totalCount).toBe(
|
||||
0
|
||||
);
|
||||
|
||||
resolveFetch?.(
|
||||
new Response(JSON.stringify({ data: { id: "survey_1" } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: surveyKeys.lists() });
|
||||
});
|
||||
|
||||
test("rolls the cache back when delete fails", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
title: "Forbidden",
|
||||
status: 403,
|
||||
detail: "You are not authorized to access this resource",
|
||||
code: "forbidden",
|
||||
requestId: "req_1",
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/problem+json" },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: { retry: false },
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
const queryKey = surveyKeys.list({
|
||||
workspaceId: "env_1",
|
||||
limit: 20,
|
||||
filters: {
|
||||
name: "",
|
||||
status: [],
|
||||
type: [],
|
||||
sortBy: "relevance",
|
||||
},
|
||||
});
|
||||
queryClient.setQueryData(queryKey, createQueryData());
|
||||
|
||||
const { result } = renderHook(() => useDeleteSurvey({ queryKey }), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.mutateAsync({ surveyId: "survey_1" });
|
||||
})
|
||||
).rejects.toThrow("You are not authorized to access this resource");
|
||||
|
||||
expect(queryClient.getQueryData<{ pages: TSurveyListPage[] }>(queryKey)?.pages[0]?.data).toHaveLength(1);
|
||||
expect(queryClient.getQueryData<{ pages: TSurveyListPage[] }>(queryKey)?.pages[0]?.meta.totalCount).toBe(
|
||||
1
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { InfiniteData, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { removeSurveyFromInfiniteData, surveyKeys } from "@/modules/survey/list/lib/query";
|
||||
import { TSurveyListPage, deleteSurvey } from "@/modules/survey/list/lib/v3-surveys-client";
|
||||
|
||||
export const useDeleteSurvey = ({ queryKey }: { queryKey: ReturnType<typeof surveyKeys.list> }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ surveyId }: { surveyId: string }) => deleteSurvey(surveyId),
|
||||
onMutate: async ({ surveyId }) => {
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
|
||||
const previousData = queryClient.getQueryData<InfiniteData<TSurveyListPage>>(queryKey);
|
||||
|
||||
queryClient.setQueryData<InfiniteData<TSurveyListPage> | undefined>(queryKey, (currentData) =>
|
||||
removeSurveyFromInfiniteData(currentData, surveyId)
|
||||
);
|
||||
|
||||
return {
|
||||
previousData,
|
||||
};
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(queryKey, context.previousData);
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: surveyKeys.lists() });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { type ReactNode, createElement } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
|
||||
import { useSurveys } from "./use-surveys";
|
||||
|
||||
function createWrapper(queryClient: QueryClient) {
|
||||
const Wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
Wrapper.displayName = "UseSurveysTestWrapper";
|
||||
|
||||
return Wrapper;
|
||||
}
|
||||
|
||||
describe("useSurveys", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("fetches the initial page and the next cursor page", async () => {
|
||||
const fetchMock = vi.mocked(global.fetch);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: "survey_1",
|
||||
name: "Survey 1",
|
||||
workspaceId: "env_1",
|
||||
type: "link",
|
||||
status: "draft",
|
||||
createdAt: "2026-04-15T10:00:00.000Z",
|
||||
updatedAt: "2026-04-15T10:00:00.000Z",
|
||||
responseCount: 0,
|
||||
creator: { name: "Alice" },
|
||||
singleUse: null,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
limit: 20,
|
||||
nextCursor: "cursor_1",
|
||||
totalCount: 2,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: "survey_2",
|
||||
name: "Survey 2",
|
||||
workspaceId: "env_1",
|
||||
type: "app",
|
||||
status: "completed",
|
||||
createdAt: "2026-04-15T11:00:00.000Z",
|
||||
updatedAt: "2026-04-15T11:00:00.000Z",
|
||||
responseCount: 2,
|
||||
creator: { name: "Bob" },
|
||||
singleUse: null,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
limit: 20,
|
||||
nextCursor: null,
|
||||
totalCount: 2,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSurveys({
|
||||
workspaceId: "env_1",
|
||||
limit: 20,
|
||||
filters: {
|
||||
name: "",
|
||||
status: [],
|
||||
type: [],
|
||||
sortBy: "relevance",
|
||||
},
|
||||
}),
|
||||
{ wrapper: createWrapper(queryClient) }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.surveys).toHaveLength(1);
|
||||
expect(result.current.totalCount).toBe(2);
|
||||
expect(result.current.hasNextPage).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchNextPage();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.surveys).toHaveLength(2));
|
||||
expect(result.current.hasNextPage).toBe(false);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/v3/surveys?workspaceId=env_1&limit=20&sortBy=relevance&cursor=cursor_1",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the previous page data while refetching for new filters", async () => {
|
||||
let resolveNextResponse: ((value: Response) => void) | undefined;
|
||||
const nextResponsePromise = new Promise<Response>((resolve) => {
|
||||
resolveNextResponse = resolve;
|
||||
});
|
||||
|
||||
const fetchMock = vi.mocked(global.fetch);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: "survey_1",
|
||||
name: "Survey 1",
|
||||
workspaceId: "env_1",
|
||||
type: "link",
|
||||
status: "draft",
|
||||
createdAt: "2026-04-15T10:00:00.000Z",
|
||||
updatedAt: "2026-04-15T10:00:00.000Z",
|
||||
responseCount: 0,
|
||||
creator: { name: "Alice" },
|
||||
singleUse: null,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
limit: 20,
|
||||
nextCursor: null,
|
||||
totalCount: 1,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
)
|
||||
.mockReturnValueOnce(nextResponsePromise as Promise<Response>);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const initialFilters: TSurveyOverviewFilters = {
|
||||
name: "",
|
||||
status: [],
|
||||
type: [],
|
||||
sortBy: "relevance",
|
||||
};
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ filters }) =>
|
||||
useSurveys({
|
||||
workspaceId: "env_1",
|
||||
limit: 20,
|
||||
filters,
|
||||
}),
|
||||
{
|
||||
initialProps: { filters: initialFilters },
|
||||
wrapper: createWrapper(queryClient),
|
||||
}
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.surveys).toHaveLength(1);
|
||||
|
||||
rerender({
|
||||
filters: {
|
||||
...initialFilters,
|
||||
name: "new query",
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
expect(result.current.surveys).toHaveLength(1);
|
||||
expect(result.current.surveys[0]?.name).toBe("Survey 1");
|
||||
|
||||
resolveNextResponse?.(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: "survey_2",
|
||||
name: "Survey 2",
|
||||
workspaceId: "env_1",
|
||||
type: "link",
|
||||
status: "paused",
|
||||
createdAt: "2026-04-15T11:00:00.000Z",
|
||||
updatedAt: "2026-04-15T11:00:00.000Z",
|
||||
responseCount: 4,
|
||||
creator: { name: "Bob" },
|
||||
singleUse: null,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
limit: 20,
|
||||
nextCursor: null,
|
||||
totalCount: 1,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.surveys[0]?.name).toBe("Survey 2"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { flattenSurveyPages, surveyKeys } from "@/modules/survey/list/lib/query";
|
||||
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
|
||||
import { listSurveys } from "../lib/v3-surveys-client";
|
||||
|
||||
export const useSurveys = ({
|
||||
workspaceId,
|
||||
limit,
|
||||
filters,
|
||||
enabled = true,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
filters: TSurveyOverviewFilters;
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
const queryKey = surveyKeys.list({
|
||||
workspaceId,
|
||||
limit,
|
||||
filters,
|
||||
});
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey,
|
||||
initialPageParam: null as string | null,
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
listSurveys({
|
||||
workspaceId,
|
||||
limit,
|
||||
cursor: pageParam,
|
||||
filters,
|
||||
signal,
|
||||
}),
|
||||
getNextPageParam: (lastPage) => lastPage.meta.nextCursor ?? undefined,
|
||||
});
|
||||
|
||||
const surveys = flattenSurveyPages(query.data);
|
||||
const totalCount = query.data?.pages[0]?.meta.totalCount ?? 0;
|
||||
|
||||
return {
|
||||
...query,
|
||||
queryKey,
|
||||
surveys,
|
||||
totalCount,
|
||||
};
|
||||
};
|
||||
@@ -1,8 +1,7 @@
|
||||
import { TSurveyFilters } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
|
||||
|
||||
export const initialFilters: TSurveyFilters = {
|
||||
export const initialFilters: TSurveyOverviewFilters = {
|
||||
name: "",
|
||||
createdBy: [],
|
||||
status: [],
|
||||
type: [],
|
||||
sortBy: "relevance",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { InfiniteData } from "@tanstack/react-query";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { flattenSurveyPages, removeSurveyFromInfiniteData } from "./query";
|
||||
import { TSurveyListPage } from "./v3-surveys-client";
|
||||
|
||||
const surveyA = {
|
||||
id: "survey_a",
|
||||
name: "Survey A",
|
||||
workspaceId: "env_1",
|
||||
type: "link" as const,
|
||||
status: "draft" as const,
|
||||
createdAt: new Date("2026-04-15T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
|
||||
responseCount: 0,
|
||||
creator: { name: "Alice" },
|
||||
singleUse: null,
|
||||
};
|
||||
|
||||
const surveyB = {
|
||||
...surveyA,
|
||||
id: "survey_b",
|
||||
name: "Survey B",
|
||||
};
|
||||
|
||||
const baseData: InfiniteData<TSurveyListPage> = {
|
||||
pages: [
|
||||
{
|
||||
data: [surveyA],
|
||||
meta: {
|
||||
limit: 20,
|
||||
nextCursor: "cursor-1",
|
||||
totalCount: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
data: [surveyB],
|
||||
meta: {
|
||||
limit: 20,
|
||||
nextCursor: null,
|
||||
totalCount: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
pageParams: [null, "cursor-1"],
|
||||
};
|
||||
|
||||
describe("flattenSurveyPages", () => {
|
||||
test("flattens every fetched page", () => {
|
||||
expect(flattenSurveyPages(baseData)).toEqual([surveyA, surveyB]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeSurveyFromInfiniteData", () => {
|
||||
test("removes the survey from cached pages and decrements each page total", () => {
|
||||
const nextData = removeSurveyFromInfiniteData(baseData, "survey_a");
|
||||
|
||||
expect(nextData?.pages[0]?.data).toEqual([]);
|
||||
expect(nextData?.pages[1]?.data).toEqual([surveyB]);
|
||||
expect(nextData?.pages[0]?.meta.totalCount).toBe(1);
|
||||
expect(nextData?.pages[1]?.meta.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
test("returns the original cache when the survey is not present", () => {
|
||||
expect(removeSurveyFromInfiniteData(baseData, "missing_survey")).toBe(baseData);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { InfiniteData } from "@tanstack/react-query";
|
||||
import { TSurveyListItem, TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
|
||||
import { TSurveyListPage } from "./v3-surveys-client";
|
||||
|
||||
type TSurveyListKeyInput = {
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
filters: TSurveyOverviewFilters;
|
||||
};
|
||||
|
||||
export const surveyKeys = {
|
||||
all: ["surveys"] as const,
|
||||
lists: () => [...surveyKeys.all, "list"] as const,
|
||||
list: (input: TSurveyListKeyInput) => [...surveyKeys.lists(), input] as const,
|
||||
};
|
||||
|
||||
export function flattenSurveyPages(data?: InfiniteData<TSurveyListPage>): TSurveyListItem[] {
|
||||
return data?.pages.flatMap((page) => page.data) ?? [];
|
||||
}
|
||||
|
||||
export function removeSurveyFromInfiniteData(
|
||||
data: InfiniteData<TSurveyListPage> | undefined,
|
||||
surveyId: string
|
||||
): InfiniteData<TSurveyListPage> | undefined {
|
||||
if (!data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
let surveyWasRemoved = false;
|
||||
|
||||
const pages = data.pages.map((page) => {
|
||||
const nextData = page.data.filter((survey) => survey.id !== surveyId);
|
||||
if (nextData.length !== page.data.length) {
|
||||
surveyWasRemoved = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...page,
|
||||
data: nextData,
|
||||
};
|
||||
});
|
||||
|
||||
if (!surveyWasRemoved) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
pages: pages.map((page) => ({
|
||||
...page,
|
||||
meta: {
|
||||
...page.meta,
|
||||
totalCount: Math.max(0, page.meta.totalCount - 1),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import { TProjectWithLanguages, TSurvey } from "../types/surveys";
|
||||
// Import the module to be tested
|
||||
import {
|
||||
copySurveyToOtherEnvironment,
|
||||
deleteSurvey,
|
||||
getSurvey,
|
||||
getSurveyCount,
|
||||
getSurveys,
|
||||
@@ -420,57 +419,6 @@ describe("getSurveysSortedByRelevance", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteSurvey", () => {
|
||||
beforeEach(() => {
|
||||
resetMocks();
|
||||
});
|
||||
|
||||
const mockDeletedSurveyData = {
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
segment: null,
|
||||
type: "web" as any,
|
||||
triggers: [{ actionClass: { id: "action_1" } }],
|
||||
};
|
||||
|
||||
test("should delete a survey and revalidate caches (no private segment)", async () => {
|
||||
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyData as any);
|
||||
const result = await deleteSurvey(surveyId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.survey.delete).toHaveBeenCalledWith({
|
||||
where: { id: surveyId },
|
||||
select: expect.objectContaining({ id: true, environmentId: true, segment: expect.anything() }),
|
||||
});
|
||||
expect(prisma.segment.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should revalidate segment cache for non-private segment if segment exists", async () => {
|
||||
const surveyWithPublicSegment = {
|
||||
...mockDeletedSurveyData,
|
||||
segment: { id: "segment_public_1", isPrivate: false },
|
||||
};
|
||||
vi.mocked(prisma.survey.delete).mockResolvedValue(surveyWithPublicSegment as any);
|
||||
|
||||
await deleteSurvey(surveyId);
|
||||
|
||||
expect(prisma.segment.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma error", async () => {
|
||||
const prismaError = makePrismaKnownError();
|
||||
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
|
||||
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error deleting survey");
|
||||
});
|
||||
|
||||
test("should rethrow unknown error", async () => {
|
||||
const unknownError = new Error("Unknown error");
|
||||
vi.mocked(prisma.survey.delete).mockRejectedValue(unknownError);
|
||||
await expect(deleteSurvey(surveyId)).rejects.toThrow(unknownError);
|
||||
});
|
||||
});
|
||||
|
||||
const mockExistingSurveyDetails = {
|
||||
name: "Original Survey",
|
||||
type: "web" as any,
|
||||
|
||||
@@ -145,53 +145,6 @@ export const getSurvey = reactCache(async (surveyId: string): Promise<TSurvey |
|
||||
return mapSurveyRowToSurvey(surveyPrisma);
|
||||
});
|
||||
|
||||
export const deleteSurvey = async (surveyId: string): Promise<boolean> => {
|
||||
try {
|
||||
const deletedSurvey = await prisma.survey.delete({
|
||||
where: {
|
||||
id: surveyId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
segment: {
|
||||
select: {
|
||||
id: true,
|
||||
isPrivate: true,
|
||||
},
|
||||
},
|
||||
type: true,
|
||||
triggers: {
|
||||
select: {
|
||||
actionClass: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
|
||||
await prisma.segment.delete({
|
||||
where: {
|
||||
id: deletedSurvey.segment.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error deleting survey");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getExistingSurvey = async (surveyId: string) => {
|
||||
return await prisma.survey.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -1,68 +1,98 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { TSurveyFilters } from "@formbricks/types/surveys/types";
|
||||
import { getFormattedFilters } from "./utils";
|
||||
import { hasActiveSurveyFilters, normalizeSurveyFilters, parseStoredSurveyFilters } from "./utils";
|
||||
|
||||
describe("getFormattedFilters", () => {
|
||||
test("returns empty object when no filters provided", () => {
|
||||
const result = getFormattedFilters({} as TSurveyFilters, "user1");
|
||||
expect(result).toEqual({});
|
||||
describe("normalizeSurveyFilters", () => {
|
||||
test("returns the normalized default filters when input is empty", () => {
|
||||
expect(normalizeSurveyFilters(undefined)).toEqual({
|
||||
name: "",
|
||||
status: [],
|
||||
type: [],
|
||||
sortBy: "relevance",
|
||||
});
|
||||
});
|
||||
|
||||
test("includes name filter", () => {
|
||||
const result = getFormattedFilters({ name: "surveyName" } as TSurveyFilters, "user1");
|
||||
expect(result).toEqual({ name: "surveyName" });
|
||||
});
|
||||
|
||||
test("includes status filter when array is non-empty", () => {
|
||||
const result = getFormattedFilters({ status: ["active", "inactive"] } as any, "user1");
|
||||
expect(result).toEqual({ status: ["active", "inactive"] });
|
||||
});
|
||||
|
||||
test("ignores status filter when empty array", () => {
|
||||
const result = getFormattedFilters({ status: [] } as any, "user1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("includes type filter when array is non-empty", () => {
|
||||
const result = getFormattedFilters({ type: ["typeA"] } as any, "user1");
|
||||
expect(result).toEqual({ type: ["typeA"] });
|
||||
});
|
||||
|
||||
test("ignores type filter when empty array", () => {
|
||||
const result = getFormattedFilters({ type: [] } as any, "user1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("includes createdBy filter when array is non-empty", () => {
|
||||
const result = getFormattedFilters({ createdBy: ["ownerA", "ownerB"] } as any, "user1");
|
||||
expect(result).toEqual({ createdBy: { userId: "user1", value: ["ownerA", "ownerB"] } });
|
||||
});
|
||||
|
||||
test("ignores createdBy filter when empty array", () => {
|
||||
const result = getFormattedFilters({ createdBy: [] } as any, "user1");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test("includes sortBy filter", () => {
|
||||
const result = getFormattedFilters({ sortBy: "date" } as any, "user1");
|
||||
expect(result).toEqual({ sortBy: "date" });
|
||||
});
|
||||
|
||||
test("combines multiple filters", () => {
|
||||
const input: TSurveyFilters = {
|
||||
name: "nameVal",
|
||||
status: ["draft"],
|
||||
type: ["link", "app"],
|
||||
createdBy: ["you"],
|
||||
sortBy: "name",
|
||||
};
|
||||
const result = getFormattedFilters(input, "userX");
|
||||
expect(result).toEqual({
|
||||
name: "nameVal",
|
||||
status: ["draft"],
|
||||
type: ["link", "app"],
|
||||
createdBy: { userId: "userX", value: ["you"] },
|
||||
test("trims names, removes unsupported fields, and sorts filter arrays", () => {
|
||||
expect(
|
||||
normalizeSurveyFilters({
|
||||
name: " Customer feedback ",
|
||||
createdBy: ["you"],
|
||||
status: ["paused", "draft", "paused"],
|
||||
type: ["link", "app", "link"],
|
||||
sortBy: "name",
|
||||
} as any)
|
||||
).toEqual({
|
||||
name: "Customer feedback",
|
||||
status: ["draft", "paused"],
|
||||
type: ["app", "link"],
|
||||
sortBy: "name",
|
||||
});
|
||||
});
|
||||
|
||||
test("drops type filters when the project channel is link-only", () => {
|
||||
expect(
|
||||
normalizeSurveyFilters(
|
||||
{
|
||||
name: "",
|
||||
status: [],
|
||||
type: ["app", "link"],
|
||||
sortBy: "updatedAt",
|
||||
},
|
||||
"link"
|
||||
)
|
||||
).toEqual({
|
||||
name: "",
|
||||
status: [],
|
||||
type: [],
|
||||
sortBy: "updatedAt",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseStoredSurveyFilters", () => {
|
||||
test("returns null for invalid JSON", () => {
|
||||
expect(parseStoredSurveyFilters("{")).toBeNull();
|
||||
});
|
||||
|
||||
test("sanitizes legacy stored filters", () => {
|
||||
expect(
|
||||
parseStoredSurveyFilters(
|
||||
JSON.stringify({
|
||||
name: " NPS ",
|
||||
createdBy: ["you"],
|
||||
status: ["completed", "draft"],
|
||||
type: ["link"],
|
||||
sortBy: "createdAt",
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
name: "NPS",
|
||||
status: ["completed", "draft"],
|
||||
type: ["link"],
|
||||
sortBy: "createdAt",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasActiveSurveyFilters", () => {
|
||||
test("ignores sort-only changes", () => {
|
||||
expect(
|
||||
hasActiveSurveyFilters({
|
||||
name: "",
|
||||
status: [],
|
||||
type: [],
|
||||
sortBy: "createdAt",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("detects active filters", () => {
|
||||
expect(
|
||||
hasActiveSurveyFilters({
|
||||
name: "CSAT",
|
||||
status: [],
|
||||
type: [],
|
||||
sortBy: "relevance",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,77 @@
|
||||
import { TSurveyFilterCriteria, TSurveyFilters } from "@formbricks/types/surveys/types";
|
||||
import { TProjectConfigChannel } from "@formbricks/types/project";
|
||||
import { initialFilters } from "@/modules/survey/list/lib/constants";
|
||||
import {
|
||||
TSurveyOverviewFilters,
|
||||
TSurveyOverviewSort,
|
||||
TSurveyOverviewType,
|
||||
} from "@/modules/survey/list/types/survey-overview";
|
||||
|
||||
export const getFormattedFilters = (surveyFilters: TSurveyFilters, userId: string): TSurveyFilterCriteria => {
|
||||
const filters: TSurveyFilterCriteria = {};
|
||||
const allowedStatus = new Set(["draft", "inProgress", "paused", "completed"] as const);
|
||||
const allowedType = new Set(["app", "link"] as const);
|
||||
const allowedSort = new Set(["createdAt", "updatedAt", "name", "relevance"] as const);
|
||||
const compareNormalizedFilterValues = (left: string, right: string) => left.localeCompare(right);
|
||||
|
||||
if (surveyFilters.name) {
|
||||
filters.name = surveyFilters.name;
|
||||
function getNormalizedStatus(value: unknown): TSurveyOverviewFilters["status"] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (surveyFilters.status && surveyFilters.status.length) {
|
||||
filters.status = surveyFilters.status;
|
||||
return [
|
||||
...new Set(
|
||||
value.filter((status): status is TSurveyOverviewFilters["status"][number] =>
|
||||
allowedStatus.has(status as never)
|
||||
)
|
||||
),
|
||||
].sort(compareNormalizedFilterValues);
|
||||
}
|
||||
|
||||
function getNormalizedType(
|
||||
value: unknown,
|
||||
currentProjectChannel?: TProjectConfigChannel
|
||||
): TSurveyOverviewType[] {
|
||||
if (currentProjectChannel === "link" || !Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (surveyFilters.type && surveyFilters.type.length) {
|
||||
filters.type = surveyFilters.type;
|
||||
return [
|
||||
...new Set(value.filter((type): type is TSurveyOverviewType => allowedType.has(type as never))),
|
||||
].sort(compareNormalizedFilterValues);
|
||||
}
|
||||
|
||||
function getNormalizedSort(value: unknown): TSurveyOverviewSort {
|
||||
return allowedSort.has(value as never) ? (value as TSurveyOverviewSort) : initialFilters.sortBy;
|
||||
}
|
||||
|
||||
export function normalizeSurveyFilters(
|
||||
filters: Partial<TSurveyOverviewFilters> | null | undefined,
|
||||
currentProjectChannel?: TProjectConfigChannel
|
||||
): TSurveyOverviewFilters {
|
||||
return {
|
||||
name: typeof filters?.name === "string" ? filters.name.trim() : initialFilters.name,
|
||||
status: getNormalizedStatus(filters?.status),
|
||||
type: getNormalizedType(filters?.type, currentProjectChannel),
|
||||
sortBy: getNormalizedSort(filters?.sortBy),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseStoredSurveyFilters(
|
||||
storedValue: string | null,
|
||||
currentProjectChannel?: TProjectConfigChannel
|
||||
): TSurveyOverviewFilters | null {
|
||||
if (!storedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (surveyFilters.createdBy && surveyFilters.createdBy.length) {
|
||||
filters.createdBy = {
|
||||
userId: userId,
|
||||
value: surveyFilters.createdBy,
|
||||
};
|
||||
try {
|
||||
return normalizeSurveyFilters(
|
||||
JSON.parse(storedValue) as Partial<TSurveyOverviewFilters>,
|
||||
currentProjectChannel
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (surveyFilters.sortBy) {
|
||||
filters.sortBy = surveyFilters.sortBy;
|
||||
}
|
||||
|
||||
return filters;
|
||||
};
|
||||
export function hasActiveSurveyFilters(filters: TSurveyOverviewFilters): boolean {
|
||||
return Boolean(filters.name) || filters.status.length > 0 || filters.type.length > 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { buildSurveyListSearchParams } from "./v3-surveys-client";
|
||||
|
||||
describe("buildSurveyListSearchParams", () => {
|
||||
test("emits only supported v3 params using normalized filter values", () => {
|
||||
const searchParams = buildSurveyListSearchParams({
|
||||
workspaceId: "env_1",
|
||||
limit: 20,
|
||||
cursor: "cursor_1",
|
||||
filters: {
|
||||
name: " Product feedback ",
|
||||
status: ["paused", "draft"],
|
||||
type: ["link", "app"],
|
||||
sortBy: "relevance",
|
||||
},
|
||||
});
|
||||
|
||||
expect(searchParams.toString()).toBe(
|
||||
"workspaceId=env_1&limit=20&sortBy=relevance&cursor=cursor_1&filter%5Bname%5D%5Bcontains%5D=Product+feedback&filter%5Bstatus%5D%5Bin%5D=draft&filter%5Bstatus%5D%5Bin%5D=paused&filter%5Btype%5D%5Bin%5D=app&filter%5Btype%5D%5Bin%5D=link"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { parseV3ApiError } from "@/modules/api/lib/v3-client";
|
||||
import { normalizeSurveyFilters } from "@/modules/survey/list/lib/utils";
|
||||
import { TSurveyListItem, TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
|
||||
|
||||
type TV3SurveyListItemResponse = Omit<TSurveyListItem, "createdAt" | "updatedAt"> & {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type TV3SurveyListResponse = {
|
||||
data: TV3SurveyListItemResponse[];
|
||||
meta: TSurveyListPage["meta"];
|
||||
};
|
||||
|
||||
type TV3DeleteSurveyResponse = {
|
||||
data: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TSurveyListPage = {
|
||||
data: TSurveyListItem[];
|
||||
meta: {
|
||||
limit: number;
|
||||
nextCursor: string | null;
|
||||
totalCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
function mapSurveyListItem(survey: TV3SurveyListItemResponse): TSurveyListItem {
|
||||
return {
|
||||
...survey,
|
||||
createdAt: new Date(survey.createdAt),
|
||||
updatedAt: new Date(survey.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSurveyListSearchParams({
|
||||
workspaceId,
|
||||
limit,
|
||||
cursor,
|
||||
filters,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
cursor?: string | null;
|
||||
filters: TSurveyOverviewFilters;
|
||||
}): URLSearchParams {
|
||||
const normalizedFilters = normalizeSurveyFilters(filters);
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set("workspaceId", workspaceId);
|
||||
searchParams.set("limit", String(limit));
|
||||
searchParams.set("sortBy", normalizedFilters.sortBy);
|
||||
|
||||
if (cursor) {
|
||||
searchParams.set("cursor", cursor);
|
||||
}
|
||||
|
||||
if (normalizedFilters.name) {
|
||||
searchParams.set("filter[name][contains]", normalizedFilters.name);
|
||||
}
|
||||
|
||||
normalizedFilters.status.forEach((status) => {
|
||||
searchParams.append("filter[status][in]", status);
|
||||
});
|
||||
|
||||
normalizedFilters.type.forEach((type) => {
|
||||
searchParams.append("filter[type][in]", type);
|
||||
});
|
||||
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
export async function listSurveys({
|
||||
workspaceId,
|
||||
limit,
|
||||
cursor,
|
||||
filters,
|
||||
signal,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
cursor?: string | null;
|
||||
filters: TSurveyOverviewFilters;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<TSurveyListPage> {
|
||||
const response = await fetch(
|
||||
`/api/v3/surveys?${buildSurveyListSearchParams({ workspaceId, limit, cursor, filters }).toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
signal,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw await parseV3ApiError(response);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as TV3SurveyListResponse;
|
||||
|
||||
return {
|
||||
data: body.data.map(mapSurveyListItem),
|
||||
meta: body.meta,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteSurvey(surveyId: string): Promise<{ id: string }> {
|
||||
const response = await fetch(`/api/v3/surveys/${surveyId}`, {
|
||||
method: "DELETE",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw await parseV3ApiError(response);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as TV3DeleteSurveyResponse;
|
||||
return body.data;
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD, SURVEYS_PER_PAGE } from "@/lib/constants";
|
||||
@@ -11,11 +9,6 @@ import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
|
||||
import { SurveysList } from "@/modules/survey/list/components/survey-list";
|
||||
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
|
||||
import { TemplateContainerWithPreview } from "@/modules/survey/templates/components/template-container";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Your Surveys",
|
||||
@@ -44,65 +37,24 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
|
||||
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
|
||||
}
|
||||
|
||||
const surveyCount = await getSurveyCount(params.environmentId);
|
||||
|
||||
const currentProjectChannel = project.config.channel ?? null;
|
||||
const locale = (await getUserLocale(session.user.id)) ?? DEFAULT_LOCALE;
|
||||
const createSurveyButton = (
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys/templates`}>
|
||||
{t("environments.surveys.new_survey")}
|
||||
<PlusIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const projectWithRequiredProps = {
|
||||
...project,
|
||||
brandColor: project.styling?.brandColor?.light ?? null,
|
||||
highlightBorderColor: null,
|
||||
};
|
||||
|
||||
if (surveyCount === 0)
|
||||
return (
|
||||
<TemplateContainerWithPreview
|
||||
userId={session.user.id}
|
||||
environment={environment}
|
||||
project={projectWithRequiredProps}
|
||||
isTemplatePage={false}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
);
|
||||
|
||||
let content;
|
||||
if (surveyCount > 0) {
|
||||
content = (
|
||||
<>
|
||||
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : createSurveyButton} />
|
||||
<SurveysList
|
||||
environmentId={environment.id}
|
||||
isReadOnly={isReadOnly}
|
||||
publicDomain={publicDomain}
|
||||
userId={session.user.id}
|
||||
surveysPerPage={SURVEYS_PER_PAGE}
|
||||
currentProjectChannel={currentProjectChannel}
|
||||
locale={locale}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (isReadOnly) {
|
||||
content = (
|
||||
<>
|
||||
<h1 className="px-6 text-3xl font-extrabold text-slate-700">
|
||||
{t("environments.surveys.no_surveys_created_yet")}
|
||||
</h1>
|
||||
|
||||
<h2 className="px-6 text-lg font-medium text-slate-500">
|
||||
{t("environments.surveys.read_only_user_not_allowed_to_create_survey_warning")}
|
||||
</h2>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <PageContentWrapper>{content}</PageContentWrapper>;
|
||||
return (
|
||||
<SurveysList
|
||||
environment={environment}
|
||||
project={projectWithRequiredProps}
|
||||
isReadOnly={isReadOnly}
|
||||
publicDomain={publicDomain}
|
||||
userId={session.user.id}
|
||||
surveysPerPage={SURVEYS_PER_PAGE}
|
||||
currentProjectChannel={currentProjectChannel}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from "zod";
|
||||
import { ZSurveyStatus } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const ZSurveyOverviewType = z.enum(["link", "app"]);
|
||||
export const ZSurveyOverviewSort = z.enum(["createdAt", "updatedAt", "name", "relevance"]);
|
||||
export const ZSurveyOverviewFilters = z.object({
|
||||
name: z.string(),
|
||||
status: z.array(ZSurveyStatus),
|
||||
type: z.array(ZSurveyOverviewType),
|
||||
sortBy: ZSurveyOverviewSort,
|
||||
});
|
||||
|
||||
export const ZSurveyListItem = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
workspaceId: z.string(),
|
||||
type: z.enum(["link", "app", "website", "web"]),
|
||||
status: ZSurveyStatus,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
responseCount: z.number(),
|
||||
creator: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
singleUse: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
isEncrypted: z.boolean(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type TSurveyOverviewType = z.infer<typeof ZSurveyOverviewType>;
|
||||
export type TSurveyOverviewStatus = z.infer<typeof ZSurveyStatus>;
|
||||
export type TSurveyOverviewSort = z.infer<typeof ZSurveyOverviewSort>;
|
||||
export type TSurveyOverviewFilters = z.infer<typeof ZSurveyOverviewFilters>;
|
||||
export type TSurveyListItem = z.infer<typeof ZSurveyListItem>;
|
||||
@@ -26,17 +26,6 @@ export const ZSurvey = z.object({
|
||||
|
||||
export type TSurvey = z.infer<typeof ZSurvey>;
|
||||
|
||||
export const ZSurveyCopyFormValidation = z.object({
|
||||
projects: z.array(
|
||||
z.object({
|
||||
project: z.string(),
|
||||
environments: z.array(z.string()),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type TSurveyCopyFormData = z.infer<typeof ZSurveyCopyFormValidation>;
|
||||
|
||||
export interface TProjectWithLanguages extends Pick<Project, "id"> {
|
||||
languages: Pick<Language, "code" | "alias">[];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ checksums:
|
||||
common/back: f541015a827e37cb3b1234e56bc2aa3c
|
||||
common/close_survey: 36e6aaa19051cb253aa155ad69a9edbc
|
||||
common/company_logo: 82d5c0d5994508210ee02d684819f4b8
|
||||
common/failed_to_load_booking_widget: 6fcdeae283dc6c08cc8186c3751ecf24
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/language_switch: fd72a9ada13f672f4fd5da863b22cc46
|
||||
common/next: 89ddbcf710eba274963494f312bdc8a9
|
||||
common/no_results_found: 5518f2865757dc73900aa03ef8be6934
|
||||
common/open_booking_page_directly_at: 5f51eb388be802279f52b0eda32985e7
|
||||
common/open_in_new_tab: 6844e4922a7a40a7ee25c10ea109cdeb
|
||||
common/people_responded: b685fb877090d8658db724ad07a0dbd8
|
||||
common/please_retry_now_or_try_again_later: 949a3841e2eb01fa249790a42bf23aa5
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"back": "رجوع",
|
||||
"close_survey": "إغلاق الاستبيان",
|
||||
"company_logo": "شعار الشركة",
|
||||
"failed_to_load_booking_widget": "فشل تحميل نافذة الحجز. قد تكون بيئتك تحظر الموارد عبر النطاقات.",
|
||||
"finish": "إنهاء",
|
||||
"language_switch": "تبديل اللغة",
|
||||
"next": "التالي",
|
||||
"no_results_found": "لم يتم العثور على نتائج",
|
||||
"open_booking_page_directly_at": "جرب فتح صفحة الحجز مباشرة على",
|
||||
"open_in_new_tab": "فتح في علامة تبويب جديدة",
|
||||
"people_responded": "{count, plural, one {شخص واحد استجاب} two {شخصان استجابا} few {{count} أشخاص استجابوا} many {{count} شخصًا استجابوا} other {{count} شخص استجابوا}}",
|
||||
"please_retry_now_or_try_again_later": "يرجى إعادة المحاولة الآن أو المحاولة مرة أخرى لاحقًا.",
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"back": "Tilbage",
|
||||
"close_survey": "Luk undersøgelse",
|
||||
"company_logo": "Firmalogo",
|
||||
"failed_to_load_booking_widget": "Kunne ikke indlæse bookingwidget. Dit miljø blokerer muligvis ressourcer på tværs af domæner.",
|
||||
"finish": "Afslut",
|
||||
"language_switch": "Sprogskift",
|
||||
"next": "Næste",
|
||||
"no_results_found": "Ingen resultater fundet",
|
||||
"open_booking_page_directly_at": "Prøv at åbne bookingsiden direkte på",
|
||||
"open_in_new_tab": "Åbn i ny fane",
|
||||
"people_responded": "{count, plural, one {1 person har svaret} other {{count} personer har svaret}}",
|
||||
"please_retry_now_or_try_again_later": "Prøv igen nu eller prøv senere.",
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"back": "Zurück",
|
||||
"close_survey": "Umfrage schließen",
|
||||
"company_logo": "Firmenlogo",
|
||||
"failed_to_load_booking_widget": "Das Buchungs-Widget konnte nicht geladen werden. Deine Umgebung blockiert möglicherweise Cross-Origin-Ressourcen.",
|
||||
"finish": "Fertig",
|
||||
"language_switch": "Sprachwechsel",
|
||||
"next": "Weiter",
|
||||
"no_results_found": "Keine Ergebnisse gefunden",
|
||||
"open_booking_page_directly_at": "Versuch, die Buchungsseite direkt zu öffnen unter",
|
||||
"open_in_new_tab": "In neuem Tab öffnen",
|
||||
"people_responded": "{count, plural, one {1 Person hat geantwortet} other {{count} Personen haben geantwortet}}",
|
||||
"please_retry_now_or_try_again_later": "Bitte versuchen Sie es jetzt erneut oder später noch einmal.",
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"back": "Back",
|
||||
"close_survey": "Close survey",
|
||||
"company_logo": "Company Logo",
|
||||
"failed_to_load_booking_widget": "Failed to load booking widget. Your environment may be blocking cross-origin resources.",
|
||||
"finish": "Finish",
|
||||
"language_switch": "Language switch",
|
||||
"next": "Next",
|
||||
"no_results_found": "No results found",
|
||||
"open_booking_page_directly_at": "Try opening the booking page directly at",
|
||||
"open_in_new_tab": "Open in new tab",
|
||||
"people_responded": "{count, plural, one {1 person responded} other {{count} people responded}}",
|
||||
"please_retry_now_or_try_again_later": "Please retry now or try again later.",
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"back": "Atrás",
|
||||
"close_survey": "Cerrar encuesta",
|
||||
"company_logo": "Logo de la empresa",
|
||||
"failed_to_load_booking_widget": "No se pudo cargar el widget de reservas. Tu entorno puede estar bloqueando recursos de origen cruzado.",
|
||||
"finish": "Finalizar",
|
||||
"language_switch": "Cambio de idioma",
|
||||
"next": "Siguiente",
|
||||
"no_results_found": "No se encontraron resultados",
|
||||
"open_booking_page_directly_at": "Intenta abrir la página de reservas directamente en",
|
||||
"open_in_new_tab": "Abrir en nueva pestaña",
|
||||
"people_responded": "{count, plural, one {1 persona respondió} other {{count} personas respondieron}}",
|
||||
"please_retry_now_or_try_again_later": "Por favor, inténtalo ahora o prueba más tarde.",
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"back": "Tagasi",
|
||||
"close_survey": "Sulge küsitlus",
|
||||
"company_logo": "Ettevõtte logo",
|
||||
"failed_to_load_booking_widget": "Broneerimisvidina laadimine ebaõnnestus. Sinu keskkond võib blokeerida ristdomeenide ressursse.",
|
||||
"finish": "Lõpeta",
|
||||
"language_switch": "Keele vahetamine",
|
||||
"next": "Edasi",
|
||||
"no_results_found": "Tulemusi ei leitud",
|
||||
"open_booking_page_directly_at": "Proovi avada broneerimislehte otse aadressil",
|
||||
"open_in_new_tab": "Ava uuel vahelehel",
|
||||
"people_responded": "{count, plural, one {1 inimene vastas} other {{count} inimest vastas}}",
|
||||
"please_retry_now_or_try_again_later": "Palun proovi uuesti kohe või hiljem.",
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"back": "Retour",
|
||||
"close_survey": "Fermer le sondage",
|
||||
"company_logo": "Logo de l'entreprise",
|
||||
"failed_to_load_booking_widget": "Échec du chargement du widget de réservation. Votre environnement bloque peut-être les ressources d'origine croisée.",
|
||||
"finish": "Terminer",
|
||||
"language_switch": "Changement de langue",
|
||||
"next": "Suivant",
|
||||
"no_results_found": "Aucun résultat trouvé",
|
||||
"open_booking_page_directly_at": "Essayez d'ouvrir la page de réservation directement à l'adresse",
|
||||
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
|
||||
"people_responded": "{count, plural, one {1 personne a répondu} other {{count} personnes ont répondu}}",
|
||||
"please_retry_now_or_try_again_later": "Veuillez réessayer maintenant ou réessayer plus tard.",
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"back": "वापस",
|
||||
"close_survey": "सर्वेक्षण बंद करें",
|
||||
"company_logo": "कंपनी लोगो",
|
||||
"failed_to_load_booking_widget": "बुकिंग विजेट लोड करने में विफल। आपका एनवायरनमेंट क्रॉस-ओरिजिन संसाधनों को ब्लॉक कर रहा है।",
|
||||
"finish": "समाप्त करें",
|
||||
"language_switch": "भाषा बदलें",
|
||||
"next": "अगला",
|
||||
"no_results_found": "कोई परिणाम नहीं मिला",
|
||||
"open_booking_page_directly_at": "बुकिंग पेज को सीधे यहां खोलने का प्रयास करें",
|
||||
"open_in_new_tab": "नए टैब में खोलें",
|
||||
"people_responded": "{count, plural, one {1 व्यक्ति ने जवाब दिया} other {{count} लोगों ने जवाब दिया}}",
|
||||
"please_retry_now_or_try_again_later": "कृपया अभी पुनः प्रयास करें या बाद में फिर से प्रयास करें।",
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
"back": "Vissza",
|
||||
"close_survey": "Kérdőív lezárása",
|
||||
"company_logo": "Vállalat logója",
|
||||
"failed_to_load_booking_widget": "A foglalási modul betöltése sikertelen. Lehet, hogy a környezeted blokkolja a különböző forrásokból származó erőforrásokat.",
|
||||
"finish": "Befejezés",
|
||||
"language_switch": "Nyelvválasztó",
|
||||
"next": "Következő",
|
||||
"no_results_found": "Nincs találat",
|
||||
"open_booking_page_directly_at": "Próbáld meg közvetlenül megnyitni a foglalási oldalt itt:",
|
||||
"open_in_new_tab": "Megnyitás új lapon",
|
||||
"people_responded": "{count, plural, one {1 személy válaszolt} other {{count} személy válaszolt}}",
|
||||
"please_retry_now_or_try_again_later": "Próbálkozzon újra most, vagy próbálja meg később újra.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user