Compare commits

..

1 Commits

Author SHA1 Message Date
TheodorTomas 5ad3c009c2 WIP squash this commit 2026-02-09 18:28:40 +07:00
81 changed files with 362 additions and 1025 deletions
@@ -25,7 +25,7 @@ const mockProject: TProject = {
},
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
languages: [],
logo: null,
@@ -64,7 +64,7 @@ const mockProject = {
linkSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
languages: [],
} as unknown as TProject;
@@ -1,314 +0,0 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentStateData } from "./data";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/modules/survey/lib/utils", () => ({
transformPrismaSurvey: vi.fn((survey) => survey),
}));
const environmentId = "cjld2cjxh0000qzrmn831i7rn";
const mockEnvironmentData = {
id: environmentId,
type: "production",
appSetupCompleted: true,
project: {
id: "project-123",
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
organization: {
id: "org-123",
billing: {
plan: "free",
limits: { monthly: { responses: 100 } },
},
},
},
actionClasses: [
{
id: "action-1",
type: "code",
name: "Test Action",
key: "test-action",
noCodeConfig: null,
},
],
surveys: [
{
id: "survey-1",
name: "Test Survey",
type: "app",
status: "inProgress",
welcomeCard: { enabled: false },
questions: [],
blocks: null,
variables: [],
showLanguageSwitch: false,
languages: [],
endings: [],
autoClose: null,
styling: null,
recaptcha: { enabled: false },
segment: null,
recontactDays: null,
displayLimit: null,
displayOption: "displayOnce",
hiddenFields: { enabled: false },
isBackButtonHidden: false,
triggers: [],
displayPercentage: null,
delay: 0,
projectOverwrites: null,
},
],
};
describe("getEnvironmentStateData", () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return environment state data when environment exists", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironmentData as never);
const result = await getEnvironmentStateData(environmentId);
expect(result).toEqual({
environment: {
id: environmentId,
type: "production",
appSetupCompleted: true,
project: {
id: "project-123",
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
},
},
organization: {
id: "org-123",
billing: {
plan: "free",
limits: { monthly: { responses: 100 } },
},
},
surveys: mockEnvironmentData.surveys,
actionClasses: mockEnvironmentData.actionClasses,
});
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: { id: environmentId },
select: expect.objectContaining({
id: true,
type: true,
appSetupCompleted: true,
project: expect.any(Object),
actionClasses: expect.any(Object),
surveys: expect.any(Object),
}),
});
});
test("should throw ResourceNotFoundError when environment is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("environment");
});
test("should throw ResourceNotFoundError when project is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: null,
} as never);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw ResourceNotFoundError when organization is not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: {
...mockEnvironmentData.project,
organization: null,
},
} as never);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma database errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Connection failed", {
code: "P2024",
clientVersion: "5.0.0",
});
vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalled();
});
test("should rethrow unexpected errors", async () => {
const unexpectedError = new Error("Unexpected error");
vi.mocked(prisma.environment.findUnique).mockRejectedValue(unexpectedError);
await expect(getEnvironmentStateData(environmentId)).rejects.toThrow("Unexpected error");
expect(logger.error).toHaveBeenCalled();
});
test("should handle empty surveys array", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
surveys: [],
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.surveys).toEqual([]);
});
test("should handle empty actionClasses array", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
actionClasses: [],
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.actionClasses).toEqual([]);
});
test("should transform surveys using transformPrismaSurvey", async () => {
const multipleSurveys = [
...mockEnvironmentData.surveys,
{
...mockEnvironmentData.surveys[0],
id: "survey-2",
name: "Second Survey",
},
];
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
surveys: multipleSurveys,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.surveys).toHaveLength(2);
});
test("should correctly map project properties to environment.project", async () => {
const customProject = {
...mockEnvironmentData.project,
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
placement: "center",
inAppSurveyBranding: false,
styling: { allowStyleOverwrite: true, brandColor: "#ff0000" },
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: customProject,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.project).toEqual({
id: "project-123",
recontactDays: 14,
clickOutsideClose: false,
overlay: "dark",
placement: "center",
inAppSurveyBranding: false,
styling: { allowStyleOverwrite: true, brandColor: "#ff0000" },
});
});
test("should validate environmentId input", async () => {
// Invalid CUID should throw validation error
await expect(getEnvironmentStateData("invalid-id")).rejects.toThrow();
});
test("should handle different environment types", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
type: "development",
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.type).toBe("development");
});
test("should handle appSetupCompleted false", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
appSetupCompleted: false,
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.environment.appSetupCompleted).toBe(false);
});
test("should correctly extract organization billing data", async () => {
const customBilling = {
plan: "enterprise",
stripeCustomerId: "cus_123",
limits: {
monthly: { responses: 10000, miu: 50000 },
projects: 100,
},
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue({
...mockEnvironmentData,
project: {
...mockEnvironmentData.project,
organization: {
id: "org-enterprise",
billing: customBilling,
},
},
} as never);
const result = await getEnvironmentStateData(environmentId);
expect(result.organization).toEqual({
id: "org-enterprise",
billing: customBilling,
});
});
});
@@ -54,7 +54,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
id: true,
recontactDays: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
placement: true,
inAppSurveyBranding: true,
styling: true,
@@ -174,7 +174,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
id: environmentData.project.id,
recontactDays: environmentData.project.recontactDays,
clickOutsideClose: environmentData.project.clickOutsideClose,
overlay: environmentData.project.overlay,
darkOverlay: environmentData.project.darkOverlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: environmentData.project.styling,
@@ -58,7 +58,7 @@ const mockProject: TJsEnvironmentStateProject = {
inAppSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {
allowStyleOverwrite: false,
},
@@ -1,15 +1,13 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -33,38 +31,6 @@ const handleDatabaseError = (error: Error, url: string, endpoint: string, respon
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
const validateResponse = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
) => {
// Validate response data against validation rules
const mergedData = {
...response.data,
...responseUpdateInput.data,
};
const isFinished = responseUpdateInput.finished ?? false;
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
isFinished,
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const PUT = withV1ApiWrapper({
handler: async ({
req,
@@ -147,11 +113,6 @@ export const PUT = withV1ApiWrapper({
};
}
const validationResult = validateResponse(response, survey, inputValidation.data);
if (validationResult) {
return validationResult;
}
// update response with quota evaluation
let updatedResponse;
try {
@@ -6,14 +6,12 @@ import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -35,27 +33,6 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) => {
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
@@ -146,11 +123,6 @@ export const POST = withV1ApiWrapper({
};
}
const validationResult = validateResponse(responseInputData, survey);
if (validationResult) {
return validationResult;
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInput["meta"] = {
@@ -8,7 +8,10 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { sendToPipeline } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
@@ -146,7 +149,6 @@ export const PUT = withV1ApiWrapper({
result.survey.blocks,
responseUpdate.data,
responseUpdate.language ?? "en",
responseUpdate.finished,
result.survey.questions
);
@@ -7,7 +7,10 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import {
@@ -155,7 +158,6 @@ export const POST = withV1ApiWrapper({
surveyResult.survey.blocks,
responseInput.data,
responseInput.language ?? "en",
responseInput.finished,
surveyResult.survey.questions
);
@@ -11,7 +11,6 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -107,23 +106,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);
if (validationErrors) {
return responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
);
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInputV2["meta"] = {
+2 -2
View File
@@ -258,7 +258,6 @@ checksums:
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
common/no_overlay: 03cde9e91f08e4dd539d788e1e01407f
common/no_quotas_found: 19dea6bcc39b579351073b3974990cb6
common/no_result_found: fedddbc0149972ea072a9e063198a16d
common/no_results: 0e9b73265c6542240f5a3bf6b43e9280
@@ -285,7 +284,6 @@ checksums:
common/organization_teams_not_found: ce29fcb7a4e8b4582f92b65dea9b7d4e
common/other: 79acaa6cd481262bea4e743a422529d2
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
common/password: 223a61cf906ab9c40d22612c588dff48
common/paused: edb1f7b7219e1c9b7aa67159090d6991
@@ -1152,6 +1150,7 @@ checksums:
environments/surveys/edit/caution_explanation_responses_are_safe: 090ff00b7922a49c273e67c5f364730d
environments/surveys/edit/caution_recommendation: b15090fe878ff17f2ee7cc2082dd9018
environments/surveys/edit/caution_text: 3291e962c0e4c4656832837ddc512918
environments/surveys/edit/centered_modal_overlay_color: 1124ba61ee2ecb18a7175ff780dc3b60
environments/surveys/edit/change_anyway: 6377497d40373f6d0f082670194981ab
environments/surveys/edit/change_background: fa71a993869f7d3ac553c547c12c3e9b
environments/surveys/edit/change_question_type: 2d555ae48df8dbedfc6a4e1ad492f4aa
@@ -1942,6 +1941,7 @@ checksums:
environments/workspace/look/add_background_color_description: adb6fcb392862b3d0e9420d9b5405ddb
environments/workspace/look/app_survey_placement: f09cddac6bbb77d4694df223c6edf6b6
environments/workspace/look/app_survey_placement_settings_description: d81bcff7a866a2f83ff76936dbad4770
environments/workspace/look/centered_modal_overlay_color: 1124ba61ee2ecb18a7175ff780dc3b60
environments/workspace/look/email_customization: ae399f381183a4fe0ffd41ab496b5d8f
environments/workspace/look/email_customization_description: 5ccaf1769b2c39d7e87f3a08d056a374
environments/workspace/look/enable_custom_styling: 4774d8fb009c27044aa0191ebcccdcc2
+9 -9
View File
@@ -48,7 +48,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -106,7 +106,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -171,7 +171,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -196,7 +196,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -250,7 +250,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -324,7 +324,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -378,7 +378,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -403,7 +403,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
@@ -448,7 +448,7 @@ describe("Project Service", () => {
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [],
styling: {
allowStyleOverwrite: true,
+1 -1
View File
@@ -22,7 +22,7 @@ const selectProject = {
config: true,
placement: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
environments: true,
styling: true,
logo: true,
+1 -1
View File
@@ -85,7 +85,7 @@ export const mockProject: TProject = {
inAppSurveyBranding: false,
placement: "bottomRight",
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
environments: [],
languages: [],
config: {
+2 -2
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Kein Hintergrundbild gefunden.",
"no_code": "No Code",
"no_files_uploaded": "Keine Dateien hochgeladen",
"no_overlay": "Kein Overlay",
"no_quotas_found": "Keine Kontingente gefunden",
"no_result_found": "Kein Ergebnis gefunden",
"no_results": "Keine Ergebnisse",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
"other": "Andere",
"others": "Andere",
"overlay_color": "Overlay-Farbe",
"overview": "Überblick",
"password": "Passwort",
"paused": "Pausiert",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Ältere und neuere Antworten vermischen sich, was zu irreführenden Datensummen führen kann.",
"caution_recommendation": "Dies kann im Umfrageübersicht zu Dateninkonsistenzen führen. Wir empfehlen stattdessen, die Umfrage zu duplizieren.",
"caution_text": "Änderungen werden zu Inkonsistenzen führen",
"centered_modal_overlay_color": "Zentrierte modale Überlagerungsfarbe",
"change_anyway": "Trotzdem ändern",
"change_background": "Hintergrund ändern",
"change_question_type": "Fragetyp ändern",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Füge dem Logo-Container eine Hintergrundfarbe hinzu.",
"app_survey_placement": "Platzierung der App-Umfrage",
"app_survey_placement_settings_description": "Ändere, wo Umfragen in deiner Web-App oder Website angezeigt werden.",
"centered_modal_overlay_color": "Zentrierte modale Überlagerungsfarbe",
"email_customization": "E-Mail-Anpassung",
"email_customization_description": "Ändere das Aussehen und die Gestaltung von E-Mails, die Formbricks in deinem Namen versendet.",
"enable_custom_styling": "Benutzerdefiniertes Styling aktivieren",
+2 -2
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "No background image found.",
"no_code": "No code",
"no_files_uploaded": "No files were uploaded",
"no_overlay": "No overlay",
"no_quotas_found": "No quotas found",
"no_result_found": "No result found",
"no_results": "No results",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Organization teams not found",
"other": "Other",
"others": "Others",
"overlay_color": "Overlay color",
"overview": "Overview",
"password": "Password",
"paused": "Paused",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Older and newer responses get mixed which can lead to misleading data summaries.",
"caution_recommendation": "This may cause data inconsistencies in the survey summary. We recommend duplicating the survey instead.",
"caution_text": "Changes will lead to inconsistencies",
"centered_modal_overlay_color": "Centered modal overlay color",
"change_anyway": "Change anyway",
"change_background": "Change background",
"change_question_type": "Change question type",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Add a background color to the logo container.",
"app_survey_placement": "App Survey Placement",
"app_survey_placement_settings_description": "Change where surveys will be shown in your web app or website.",
"centered_modal_overlay_color": "Centered modal overlay color",
"email_customization": "Email Customization",
"email_customization_description": "Change the look and feel of emails Formbricks sends out on your behalf.",
"enable_custom_styling": "Enable custom styling",
+2 -2
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "No se encontró imagen de fondo.",
"no_code": "Sin código",
"no_files_uploaded": "No se subieron archivos",
"no_overlay": "Sin superposición",
"no_quotas_found": "No se encontraron cuotas",
"no_result_found": "No se encontró resultado",
"no_results": "Sin resultados",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Equipos de la organización no encontrados",
"other": "Otro",
"others": "Otros",
"overlay_color": "Color de superposición",
"overview": "Resumen",
"password": "Contraseña",
"paused": "Pausado",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Las respuestas antiguas y nuevas se mezclan, lo que puede llevar a resúmenes de datos engañosos.",
"caution_recommendation": "Esto puede causar inconsistencias de datos en el resumen de la encuesta. Recomendamos duplicar la encuesta en su lugar.",
"caution_text": "Los cambios provocarán inconsistencias",
"centered_modal_overlay_color": "Color de superposición del modal centrado",
"change_anyway": "Cambiar de todos modos",
"change_background": "Cambiar fondo",
"change_question_type": "Cambiar tipo de pregunta",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Añade un color de fondo al contenedor del logotipo.",
"app_survey_placement": "Ubicación de encuesta de aplicación",
"app_survey_placement_settings_description": "Cambia dónde se mostrarán las encuestas en tu aplicación web o sitio web.",
"centered_modal_overlay_color": "Color de superposición del modal centrado",
"email_customization": "Personalización de correo electrónico",
"email_customization_description": "Cambia el aspecto de los correos electrónicos que Formbricks envía en tu nombre.",
"enable_custom_styling": "Habilitar estilo personalizado",
+2 -2
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Aucune image de fond trouvée.",
"no_code": "Sans code",
"no_files_uploaded": "Aucun fichier n'a été téléchargé.",
"no_overlay": "Aucune superposition",
"no_quotas_found": "Aucun quota trouvé",
"no_result_found": "Aucun résultat trouvé",
"no_results": "Aucun résultat",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Équipes d'organisation non trouvées",
"other": "Autre",
"others": "Autres",
"overlay_color": "Couleur de superposition",
"overview": "Aperçu",
"password": "Mot de passe",
"paused": "En pause",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Les réponses anciennes et nouvelles se mélangent, ce qui peut entraîner des résumés de données trompeurs.",
"caution_recommendation": "Cela peut entraîner des incohérences de données dans le résumé du sondage. Nous recommandons de dupliquer le sondage à la place.",
"caution_text": "Les changements entraîneront des incohérences.",
"centered_modal_overlay_color": "Couleur de superposition modale centrée",
"change_anyway": "Changer de toute façon",
"change_background": "Changer l'arrière-plan",
"change_question_type": "Changer le type de question",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Ajoutez une couleur d'arrière-plan au conteneur du logo.",
"app_survey_placement": "Placement du sondage d'application",
"app_survey_placement_settings_description": "Modifiez l'emplacement où les sondages seront affichés dans votre application web ou site web.",
"centered_modal_overlay_color": "Couleur de superposition modale centrée",
"email_customization": "Personnalisation des e-mails",
"email_customization_description": "Modifiez l'apparence des e-mails que Formbricks envoie en votre nom.",
"enable_custom_styling": "Activer le style personnalisé",
+2 -2
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Nem található háttérkép.",
"no_code": "Kód nélkül",
"no_files_uploaded": "Nem lettek fájlok feltöltve",
"no_overlay": "Nincs átfedés",
"no_quotas_found": "Nem találhatók kvóták",
"no_result_found": "Nem található eredmény",
"no_results": "Nincs találat",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "A szervezeti csapatok nem találhatók",
"other": "Egyéb",
"others": "Egyebek",
"overlay_color": "Átfedés színe",
"overview": "Áttekintés",
"password": "Jelszó",
"paused": "Szüneteltetve",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "A régebbi és az újabb válaszok összekeverednek, ami félrevezető adatösszegzésekhez vezethet.",
"caution_recommendation": "Ez adatellentmondásokat okozhat a kérdőív összegzésében. Azt javasoljuk, hogy inkább kettőzze meg a kérdőívet.",
"caution_text": "A változtatások következetlenségekhez vezetnek",
"centered_modal_overlay_color": "Középre helyezett kizárólagos rátét színe",
"change_anyway": "Változtatás mindenképp",
"change_background": "Háttér megváltoztatása",
"change_question_type": "Kérdés típusának megváltoztatása",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Hátérszín hozzáadása a logó tárolódobozához.",
"app_survey_placement": "Alkalmazás-kérdőív elhelyezése",
"app_survey_placement_settings_description": "Annak megváltoztatása, hogy a kérdőívek hol jelennek meg a webalkalmazásban vagy a webhelyen.",
"centered_modal_overlay_color": "Középre helyezett kizárólagos rátét színe",
"email_customization": "E-mail személyre szabás",
"email_customization_description": "Azon e-mailek megjelenésének megváltoztatása, amelyeket a Formbricks az Ön nevében küld ki.",
"enable_custom_styling": "Egyéni stílus engedélyezése",
+2 -2
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "背景画像が見つかりません。",
"no_code": "ノーコード",
"no_files_uploaded": "ファイルがアップロードされていません",
"no_overlay": "オーバーレイなし",
"no_quotas_found": "クォータが見つかりません",
"no_result_found": "結果が見つかりません",
"no_results": "結果なし",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "組織のチームが見つかりません",
"other": "その他",
"others": "その他",
"overlay_color": "オーバーレイの色",
"overview": "概要",
"password": "パスワード",
"paused": "一時停止",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "古い回答と新しい回答が混ざり、データの概要が誤解を招く可能性があります。",
"caution_recommendation": "これにより、フォームの概要にデータの不整合が生じる可能性があります。代わりにフォームを複製することをお勧めします。",
"caution_text": "変更は不整合を引き起こします",
"centered_modal_overlay_color": "中央モーダルのオーバーレイ色",
"change_anyway": "とにかく変更",
"change_background": "背景を変更",
"change_question_type": "質問の種類を変更",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "ロゴコンテナに背景色を追加します。",
"app_survey_placement": "アプリ内フォームの配置",
"app_survey_placement_settings_description": "Webアプリまたはウェブサイトでフォームを表示する場所を変更します。",
"centered_modal_overlay_color": "中央モーダルのオーバーレイ色",
"email_customization": "メールのカスタマイズ",
"email_customization_description": "Formbricksがあなたに代わって送信するメールの外観を変更します。",
"enable_custom_styling": "カスタムスタイルを有効化",
+2 -2
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Geen achtergrondafbeelding gevonden.",
"no_code": "Geen code",
"no_files_uploaded": "Er zijn geen bestanden geüpload",
"no_overlay": "Geen overlay",
"no_quotas_found": "Geen quota gevonden",
"no_result_found": "Geen resultaat gevonden",
"no_results": "Geen resultaten",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Organisatieteams niet gevonden",
"other": "Ander",
"others": "Anderen",
"overlay_color": "Overlaykleur",
"overview": "Overzicht",
"password": "Wachtwoord",
"paused": "Gepauzeerd",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Oudere en nieuwere antwoorden lopen door elkaar heen, wat kan leiden tot misleidende gegevenssamenvattingen.",
"caution_recommendation": "Dit kan inconsistenties in de gegevens in de onderzoekssamenvatting veroorzaken. Wij raden u aan de enquête te dupliceren.",
"caution_text": "Veranderingen zullen tot inconsistenties leiden",
"centered_modal_overlay_color": "Gecentreerde modale overlaykleur",
"change_anyway": "Hoe dan ook veranderen",
"change_background": "Achtergrond wijzigen",
"change_question_type": "Vraagtype wijzigen",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Voeg een achtergrondkleur toe aan de logocontainer.",
"app_survey_placement": "App-enquête plaatsing",
"app_survey_placement_settings_description": "Wijzig waar enquêtes worden weergegeven in uw web-app of website.",
"centered_modal_overlay_color": "Gecentreerde modale overlaykleur",
"email_customization": "E-mail aanpassing",
"email_customization_description": "Wijzig het uiterlijk van e-mails die Formbricks namens u verstuurt.",
"enable_custom_styling": "Aangepaste styling inschakelen",
+2 -2
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Imagem de fundo não encontrada.",
"no_code": "Sem código",
"no_files_uploaded": "Nenhum arquivo foi enviado",
"no_overlay": "Sem sobreposição",
"no_quotas_found": "Nenhuma cota encontrada",
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Equipes da organização não encontradas",
"other": "outro",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão Geral",
"password": "Senha",
"paused": "Pausado",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Respostas antigas e novas são misturadas, o que pode levar a resumos de dados enganosos.",
"caution_recommendation": "Isso pode causar inconsistências de dados no resumo da pesquisa. Recomendamos duplicar a pesquisa em vez disso.",
"caution_text": "Mudanças vão levar a inconsistências",
"centered_modal_overlay_color": "cor de sobreposição modal centralizada",
"change_anyway": "Mudar mesmo assim",
"change_background": "Mudar fundo",
"change_question_type": "Mudar tipo de pergunta",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Adicione uma cor de fundo ao container do logo.",
"app_survey_placement": "Posicionamento da pesquisa de app",
"app_survey_placement_settings_description": "Altere onde as pesquisas serão exibidas em seu aplicativo web ou site.",
"centered_modal_overlay_color": "Cor de sobreposição modal centralizada",
"email_customization": "Personalização de e-mail",
"email_customization_description": "Altere a aparência dos e-mails que o Formbricks envia em seu nome.",
"enable_custom_styling": "Habilitar estilização personalizada",
+2 -2
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Nenhuma imagem de fundo encontrada.",
"no_code": "Sem código",
"no_files_uploaded": "Nenhum ficheiro foi carregado",
"no_overlay": "Sem sobreposição",
"no_quotas_found": "Nenhum quota encontrado",
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Equipas da organização não encontradas",
"other": "Outro",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão geral",
"password": "Palavra-passe",
"paused": "Em pausa",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "As respostas mais antigas e mais recentes se misturam, o que pode levar a resumos de dados enganosos.",
"caution_recommendation": "Isso pode causar inconsistências de dados no resumo do inquérito. Recomendamos duplicar o inquérito em vez disso.",
"caution_text": "As alterações levarão a inconsistências",
"centered_modal_overlay_color": "Cor da sobreposição modal centralizada",
"change_anyway": "Alterar mesmo assim",
"change_background": "Alterar fundo",
"change_question_type": "Alterar tipo de pergunta",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Adicione uma cor de fundo ao contentor do logótipo.",
"app_survey_placement": "Colocação do inquérito (app)",
"app_survey_placement_settings_description": "Altere onde os inquéritos serão apresentados na sua aplicação web ou website.",
"centered_modal_overlay_color": "Cor da sobreposição modal centralizada",
"email_customization": "Personalização de e-mail",
"email_customization_description": "Altere a aparência dos e-mails que a Formbricks envia em seu nome.",
"enable_custom_styling": "Ativar estilização personalizada",
+2 -2
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
"no_code": "Fără Cod",
"no_files_uploaded": "Nu au fost încărcate fișiere",
"no_overlay": "Fără overlay",
"no_quotas_found": "Nicio cotă găsită",
"no_result_found": "Niciun rezultat găsit",
"no_results": "Nicio rezultat",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Echipele organizației nu au fost găsite",
"other": "Altele",
"others": "Altele",
"overlay_color": "Culoare overlay",
"overview": "Prezentare generală",
"password": "Parolă",
"paused": "Pauză",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Răspunsurile mai vechi și mai noi se amestecă, ceea ce poate duce la rezumate de date înșelătoare.",
"caution_recommendation": "Aceasta poate cauza inconsistențe de date în rezultatul sondajului. Vă recomandăm să duplicați sondajul în schimb.",
"caution_text": "Schimbările vor duce la inconsecvențe",
"centered_modal_overlay_color": "Culoare suprapunere modală centralizată",
"change_anyway": "Schimbă oricum",
"change_background": "Schimbați fundalul",
"change_question_type": "Schimbă tipul întrebării",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Adăugați o culoare de fundal la containerul siglei.",
"app_survey_placement": "Amplasarea sondajului în aplicație",
"app_survey_placement_settings_description": "Schimbați unde vor fi afișate sondajele în aplicația sau site-ul dvs. web.",
"centered_modal_overlay_color": "Culoare suprapunere modală centralizată",
"email_customization": "Personalizare email",
"email_customization_description": "Schimbați aspectul și stilul emailurilor trimise de Formbricks în numele dvs.",
"enable_custom_styling": "Activați stilizarea personalizată",
+2 -2
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Фоновое изображение не найдено.",
"no_code": "Нет кода",
"no_files_uploaded": "Файлы не были загружены",
"no_overlay": "Без наложения",
"no_quotas_found": "Квоты не найдены",
"no_result_found": "Результат не найден",
"no_results": "Нет результатов",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Команды организации не найдены",
"other": "Другое",
"others": "Другие",
"overlay_color": "Цвет наложения",
"overview": "Обзор",
"password": "Пароль",
"paused": "Приостановлено",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Старые и новые ответы смешиваются, что может привести к искажённым итоговым данным.",
"caution_recommendation": "Это может привести к несоответствиям в итогах опроса. Рекомендуем вместо этого дублировать опрос.",
"caution_text": "Изменения приведут к несоответствиям",
"centered_modal_overlay_color": "Цвет оверлея центрированного модального окна",
"change_anyway": "Всё равно изменить",
"change_background": "Изменить фон",
"change_question_type": "Изменить тип вопроса",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Добавьте цвет фона для контейнера с логотипом.",
"app_survey_placement": "Размещение опроса в приложении",
"app_survey_placement_settings_description": "Измените, где будут отображаться опросы в вашем веб-приложении или на сайте.",
"centered_modal_overlay_color": "Цвет оверлея центрированного модального окна",
"email_customization": "Настройка email",
"email_customization_description": "Измените внешний вид писем, которые Formbricks отправляет от вашего имени.",
"enable_custom_styling": "Включить пользовательское оформление",
+2 -2
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "Ingen bakgrundsbild hittades.",
"no_code": "Ingen kod",
"no_files_uploaded": "Inga filer laddades upp",
"no_overlay": "Ingen overlay",
"no_quotas_found": "Inga kvoter hittades",
"no_result_found": "Inget resultat hittades",
"no_results": "Inga resultat",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "Organisationsteam hittades inte",
"other": "Annat",
"others": "Andra",
"overlay_color": "Overlay-färg",
"overview": "Översikt",
"password": "Lösenord",
"paused": "Pausad",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "Äldre och nyare svar blandas vilket kan leda till vilseledande datasammanfattningar.",
"caution_recommendation": "Detta kan orsaka datainkonsekvenser i enkätsammanfattningen. Vi rekommenderar att duplicera enkäten istället.",
"caution_text": "Ändringar kommer att leda till inkonsekvenser",
"centered_modal_overlay_color": "Centrerad modal överläggsfärg",
"change_anyway": "Ändra ändå",
"change_background": "Ändra bakgrund",
"change_question_type": "Ändra frågetyp",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "Lägg till en bakgrundsfärg i logobehållaren.",
"app_survey_placement": "App-enkätplacering",
"app_survey_placement_settings_description": "Ändra var enkäter visas i din webbapp eller på din webbplats.",
"centered_modal_overlay_color": "Centrerad modal överläggsfärg",
"email_customization": "E-postanpassning",
"email_customization_description": "Ändra utseendet på de e-postmeddelanden som Formbricks skickar åt dig.",
"enable_custom_styling": "Aktivera anpassad styling",
+2 -2
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "未找到 背景 图片。",
"no_code": "无代码",
"no_files_uploaded": "没有 文件 被 上传",
"no_overlay": "无覆盖层",
"no_quotas_found": "未找到配额",
"no_result_found": "没有 结果",
"no_results": "没有 结果",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "未找到 组织 团队",
"other": "其他",
"others": "其他",
"overlay_color": "覆盖层颜色",
"overview": "概览",
"password": "密码",
"paused": "暂停",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "旧 与 新 的 回复 混合 , 这 可能 导致 数据 总结 有误 。",
"caution_recommendation": "这 可能 会 导致 调查 统计 数据 的 不一致 。 我们 建议 复制 调查 。",
"caution_text": "更改 会导致 不一致",
"centered_modal_overlay_color": "居中 模态遮罩层颜色",
"change_anyway": "还是更改",
"change_background": "更改 背景",
"change_question_type": "更改 问题类型",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "为 logo 容器添加背景色。",
"app_survey_placement": "应用调查放置位置",
"app_survey_placement_settings_description": "更改调查在您的 Web 应用或网站中显示的位置。",
"centered_modal_overlay_color": "居中模态遮罩层颜色",
"email_customization": "邮件自定义",
"email_customization_description": "更改 Formbricks 代表您发送邮件的外观和风格。",
"enable_custom_styling": "启用自定义样式",
+2 -2
View File
@@ -285,7 +285,6 @@
"no_background_image_found": "找不到背景圖片。",
"no_code": "無程式碼",
"no_files_uploaded": "沒有上傳任何檔案",
"no_overlay": "無覆蓋層",
"no_quotas_found": "找不到 配額",
"no_result_found": "找不到結果",
"no_results": "沒有結果",
@@ -312,7 +311,6 @@
"organization_teams_not_found": "找不到組織團隊",
"other": "其他",
"others": "其他",
"overlay_color": "覆蓋層顏色",
"overview": "概覽",
"password": "密碼",
"paused": "已暫停",
@@ -1223,6 +1221,7 @@
"caution_explanation_responses_are_safe": "較舊和較新的回應會混在一起,可能導致數據摘要失準。",
"caution_recommendation": "這可能導致調查摘要中的數據不一致。我們建議複製這個調查。",
"caution_text": "變更會導致不一致",
"centered_modal_overlay_color": "置中彈窗覆蓋顏色",
"change_anyway": "仍然變更",
"change_background": "變更背景",
"change_question_type": "變更問題類型",
@@ -2059,6 +2058,7 @@
"add_background_color_description": "為標誌容器新增背景顏色。",
"app_survey_placement": "應用程式問卷位置",
"app_survey_placement_settings_description": "變更問卷在您的網頁應用程式或網站中顯示的位置。",
"centered_modal_overlay_color": "置中彈窗覆蓋顏色",
"email_customization": "電子郵件自訂化",
"email_customization_description": "變更 Formbricks 代表您發送的電子郵件外觀與風格。",
"enable_custom_styling": "啟用自訂樣式",
@@ -1,6 +1,5 @@
import { z } from "zod";
import { sendToPipeline } from "@/app/lib/pipelines";
import { formatValidationErrorsForV2Api, validateResponseData } from "@/modules/api/lib/validation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { responses } from "@/modules/api/v2/lib/response";
@@ -16,6 +15,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { formatValidationErrorsForApi, validateResponseData } from "../lib/validation";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
@@ -198,7 +198,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
questionsResponse.data.blocks,
body.data,
body.language ?? "en",
body.finished,
questionsResponse.data.questions
);
@@ -207,7 +206,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
request,
{
type: "bad_request",
details: formatValidationErrorsForV2Api(validationErrors),
details: formatValidationErrorsForApi(validationErrors),
},
auditLog
);
@@ -5,10 +5,10 @@ import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
import {
formatValidationErrorsForApi,
formatValidationErrorsForV1Api,
formatValidationErrorsForV2Api,
validateResponseData,
} from "@/modules/api/lib/validation";
} from "./validation";
const mockTransformQuestionsToBlocks = vi.fn();
const mockGetElementsFromBlocks = vi.fn();
@@ -95,7 +95,7 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData([], mockResponseData, "en", true, mockQuestions);
validateResponseData([], mockResponseData, "en", mockQuestions);
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
@@ -105,15 +105,15 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, mockResponseData, "en", true, mockQuestions);
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
});
test("should return null when both blocks and questions are empty", () => {
expect(validateResponseData([], mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", true, null)).toBeNull();
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
});
test("should use default language code", () => {
@@ -124,36 +124,15 @@ describe("validateResponseData", () => {
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
});
test("should validate only present fields when finished is false", () => {
const partialResponseData: TResponseData = { element1: "test" };
const partialElements = [mockElements[0]];
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en", false);
expect(mockValidateBlockResponses).toHaveBeenCalledWith(partialElements, partialResponseData, "en");
});
test("should validate all fields when finished is true", () => {
const partialResponseData: TResponseData = { element1: "test" };
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en", true);
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, partialResponseData, "en");
});
});
describe("formatValidationErrorsForV2Api", () => {
describe("formatValidationErrorsForApi", () => {
test("should convert error map to V2 API format", () => {
const errorMap: TValidationErrorMap = {
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length required" }],
};
const result = formatValidationErrorsForV2Api(errorMap);
const result = formatValidationErrorsForApi(errorMap);
expect(result).toEqual([
{
@@ -172,7 +151,7 @@ describe("formatValidationErrorsForV2Api", () => {
],
};
const result = formatValidationErrorsForV2Api(errorMap);
const result = formatValidationErrorsForApi(errorMap);
expect(result).toHaveLength(2);
expect(result[0].field).toBe("response.data.element1");
@@ -185,7 +164,7 @@ describe("formatValidationErrorsForV2Api", () => {
element2: [{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" }],
};
const result = formatValidationErrorsForV2Api(errorMap);
const result = formatValidationErrorsForApi(errorMap);
expect(result).toHaveLength(2);
expect(result[0].field).toBe("response.data.element1");
@@ -10,20 +10,17 @@ import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
/**
* Validates response data against survey validation rules
* Handles partial responses (in-progress) by only validating present fields when finished is false
*
* @param blocks - Survey blocks containing elements with validation rules (preferred)
* @param questions - Survey questions (legacy format, used as fallback if blocks are empty)
* @param responseData - Response data to validate (keyed by element ID)
* @param languageCode - Language code for error messages (defaults to "en")
* @param finished - Whether the response is finished (defaults to true for management APIs)
* @param questions - Survey questions (legacy format, used as fallback if blocks are empty)
* @returns Validation error map keyed by element ID, or null if validation passes
*/
export const validateResponseData = (
blocks: TSurveyBlock[] | undefined | null,
responseData: TResponseData,
languageCode: string = "en",
finished: boolean = true,
questions?: TSurveyQuestion[] | undefined | null
): TValidationErrorMap | null => {
// Use blocks if available, otherwise transform questions to blocks
@@ -40,26 +37,22 @@ export const validateResponseData = (
}
// Extract elements from blocks
const allElements = getElementsFromBlocks(blocksToUse);
const elements = getElementsFromBlocks(blocksToUse);
// If response is not finished, only validate elements that are present in the response data
// This prevents "required" errors for fields the user hasn't reached yet
const elementsToValidate = finished ? allElements : allElements.filter((element) => Object.keys(responseData).includes(element.id));
// Validate selected elements
const errorMap = validateBlockResponses(elementsToValidate, responseData, languageCode);
// Validate all elements
const errorMap = validateBlockResponses(elements, responseData, languageCode);
// Return null if no errors (validation passed), otherwise return error map
return Object.keys(errorMap).length === 0 ? null : errorMap;
};
/**
* Converts validation error map to V2 API error response format
* Converts validation error map to API error response format (V2)
*
* @param errorMap - Validation error map from validateResponseData
* @returns V2 API error response details
* @returns API error response details
*/
export const formatValidationErrorsForV2Api = (errorMap: TValidationErrorMap) => {
export const formatValidationErrorsForApi = (errorMap: TValidationErrorMap) => {
const details: ApiErrorDetails = [];
for (const [elementId, errors] of Object.entries(errorMap)) {
@@ -1,7 +1,6 @@
import { Response } from "@prisma/client";
import { NextRequest } from "next/server";
import { sendToPipeline } from "@/app/lib/pipelines";
import { formatValidationErrorsForV2Api, validateResponseData } from "@/modules/api/lib/validation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { responses } from "@/modules/api/v2/lib/response";
@@ -14,6 +13,7 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
import { formatValidationErrorsForApi, validateResponseData } from "./lib/validation";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
@@ -134,7 +134,6 @@ export const POST = async (request: Request) =>
surveyQuestions.data.blocks,
body.data,
body.language ?? "en",
body.finished,
surveyQuestions.data.questions
);
@@ -143,7 +142,7 @@ export const POST = async (request: Request) =>
request,
{
type: "bad_request",
details: formatValidationErrorsForV2Api(validationErrors),
details: formatValidationErrorsForApi(validationErrors),
},
auditLog
);
@@ -36,6 +36,7 @@ import {
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Input } from "@/modules/ui/components/input";
import { InputCombobox } from "@/modules/ui/components/input-combo-box";
import {
Select,
SelectContent,
@@ -187,14 +188,16 @@ export const TeamSettingsModal = ({
const currentMemberId = watchMembers[index]?.userId;
return orgMembers
.filter((om) => !selectedMemberIds.includes(om?.id) || om?.id === currentMemberId)
.map((om) => ({ label: om?.name, value: om?.id }));
.map((om) => ({ label: om?.name, value: om?.id }))
.sort((a, b) => a.label.localeCompare(b.label));
};
const getProjectOptionsForIndex = (index: number) => {
const currentProjectId = watchProjects[index]?.projectId;
return orgProjects
.filter((op) => !selectedProjectIds.includes(op?.id) || op?.id === currentProjectId)
.map((op) => ({ label: op?.name, value: op?.id }));
.map((op) => ({ label: op?.name, value: op?.id }))
.sort((a, b) => a.label.localeCompare(b.label));
};
const handleMemberSelectionChange = (index: number, userId: string) => {
@@ -278,29 +281,21 @@ export const TeamSettingsModal = ({
return (
<FormItem className="flex-1">
<Select
onValueChange={(val) => {
field.onChange(val);
handleMemberSelectionChange(index, val);
<InputCombobox
id={`member-${index}-select`}
options={memberOpts}
value={member.userId || null}
onChangeValue={(val) => {
const userId = val as string;
field.onChange(userId);
handleMemberSelectionChange(index, userId);
}}
disabled={isSelectDisabled}
value={member.userId}>
<SelectTrigger>
<SelectValue
placeholder={t("environments.settings.teams.select_member")}
/>
</SelectTrigger>
<SelectContent>
{memberOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`member-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
disabled={!!isSelectDisabled}
comboboxClasses="max-w-full"
searchPlaceholder={t(
"environments.settings.teams.select_member"
)}
/>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
@@ -426,26 +421,19 @@ export const TeamSettingsModal = ({
return (
<FormItem className="flex-1">
<Select
onValueChange={field.onChange}
value={project.projectId}
disabled={isSelectDisabled}>
<SelectTrigger>
<SelectValue
placeholder={t("environments.settings.teams.select_workspace")}
/>
</SelectTrigger>
<SelectContent>
{projectOpts.map((option) => (
<SelectItem
key={option.value}
value={option.value}
id={`project-${index}-option`}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<InputCombobox
id={`project-${index}-select`}
options={projectOpts}
value={project.projectId || null}
onChangeValue={(val) => {
field.onChange(val as string);
}}
disabled={!!isSelectDisabled}
comboboxClasses="max-w-full"
searchPlaceholder={t(
"environments.settings.teams.select_workspace"
)}
/>
{error?.message && (
<FormError className="text-left">{error.message}</FormError>
)}
@@ -58,7 +58,7 @@ describe("updateProjectBranding", () => {
},
placement: "bottomRight" as const,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
environments: [{ id: "test-env-id" }],
languages: [],
logo: null,
@@ -183,7 +183,7 @@ export async function PreviewEmailTemplate({
{ctaElement.buttonExternal && ctaElement.ctaButtonLabel && ctaElement.buttonUrl && (
<Container className="mx-0 mt-4 flex max-w-none items-center justify-end">
<EmailButton
className="text-question-color flex items-center rounded-md border-0 bg-transparent px-3 py-3 text-base font-medium leading-4 no-underline shadow-none"
className="text-question-color flex items-center rounded-md border-0 bg-transparent px-3 py-3 text-base leading-4 font-medium no-underline shadow-none"
href={ctaElement.buttonUrl}>
<Text className="inline">
{getLocalizedValue(ctaElement.ctaButtonLabel, defaultLanguageCode)}{" "}
@@ -306,13 +306,13 @@ export async function PreviewEmailTemplate({
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
className="rounded-custom mr-3 mb-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
className="rounded-custom mr-3 mb-3 inline-block h-[150px] w-[250px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
@@ -360,11 +360,11 @@ export async function PreviewEmailTemplate({
<Container className="mx-0">
<Section className="w-full table-auto">
<Row>
<Column className="w-40 break-words px-4 py-2" />
<Column className="w-40 px-4 py-2 break-words" />
{firstQuestion.columns.map((column) => {
return (
<Column
className="text-question-color max-w-40 break-words px-4 py-2 text-center"
className="text-question-color max-w-40 px-4 py-2 text-center break-words"
key={column.id}>
{getLocalizedValue(column.label, "default")}
</Column>
@@ -376,7 +376,7 @@ export async function PreviewEmailTemplate({
<Row
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}
key={row.id}>
<Column className="w-40 break-words px-4 py-2">
<Column className="w-40 px-4 py-2 break-words">
{getLocalizedValue(row.label, "default")}
</Column>
{firstQuestion.columns.map((column) => {
@@ -241,7 +241,7 @@ describe("utils.ts", () => {
config: {},
placement: "bottomRight" as const,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {},
logo: null,
environments: [
@@ -389,7 +389,7 @@ describe("utils.ts", () => {
config: {},
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {},
logo: null,
environments: [
@@ -502,7 +502,7 @@ describe("utils.ts", () => {
config: {},
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {},
logo: null,
environments: [],
@@ -588,7 +588,7 @@ describe("utils.ts", () => {
config: {},
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {},
logo: null,
environments: [],
@@ -627,7 +627,7 @@ describe("utils.ts", () => {
config: {},
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
styling: {},
logo: null,
environments: [],
+2 -2
View File
@@ -150,7 +150,7 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
config: true,
placement: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
styling: true,
logo: true,
customHeadScripts: true,
@@ -220,7 +220,7 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
config: data.project.config,
placement: data.project.placement,
clickOutsideClose: data.project.clickOutsideClose,
overlay: data.project.overlay,
darkOverlay: data.project.darkOverlay,
styling: data.project.styling,
logo: data.project.logo,
customHeadScripts: data.project.customHeadScripts,
@@ -23,7 +23,7 @@ const baseProject = {
config: { channel: null, industry: null },
placement: "bottomRight",
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
environments: [
{
id: "cmi2sra0j000004l73fvh7lhe",
@@ -24,7 +24,7 @@ const selectProject = {
config: true,
placement: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
environments: true,
styling: true,
logo: true,
@@ -15,7 +15,6 @@ import { FormControl, FormField, FormItem, FormLabel, FormProvider } from "@/mod
import { Label } from "@/modules/ui/components/label";
import { getPlacementStyle } from "@/modules/ui/components/preview-survey/lib/utils";
import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group";
import { StylingTabs } from "@/modules/ui/components/styling-tabs";
interface EditPlacementProps {
project: Project;
@@ -25,7 +24,7 @@ interface EditPlacementProps {
const ZProjectPlacementInput = z.object({
placement: z.enum(["bottomRight", "topRight", "topLeft", "bottomLeft", "center"]),
overlay: z.enum(["none", "light", "dark"]),
darkOverlay: z.boolean(),
clickOutsideClose: z.boolean(),
});
@@ -41,35 +40,28 @@ export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) =
{ name: t("common.bottom_left"), value: "bottomLeft", disabled: false },
{ name: t("common.centered_modal"), value: "center", disabled: false },
];
const form = useForm<EditPlacementFormValues>({
defaultValues: {
placement: project.placement,
overlay: project.overlay ?? "none",
darkOverlay: project.darkOverlay ?? false,
clickOutsideClose: project.clickOutsideClose ?? false,
},
resolver: zodResolver(ZProjectPlacementInput),
});
const currentPlacement = form.watch("placement");
const overlay = form.watch("overlay");
const darkOverlay = form.watch("darkOverlay");
const clickOutsideClose = form.watch("clickOutsideClose");
const isSubmitting = form.formState.isSubmitting;
const hasOverlay = overlay !== "none";
const getOverlayStyle = () => {
if (overlay === "dark") return "bg-slate-700/80";
if (overlay === "light") return "bg-slate-400/50";
return "bg-slate-200";
};
const overlayStyle = currentPlacement === "center" && darkOverlay ? "bg-slate-700/80" : "bg-slate-200";
const onSubmit: SubmitHandler<EditPlacementFormValues> = async (data) => {
const updatedProjectResponse = await updateProjectAction({
projectId: project.id,
data: {
placement: data.placement,
overlay: data.overlay,
darkOverlay: data.darkOverlay,
clickOutsideClose: data.clickOutsideClose,
},
});
@@ -121,9 +113,9 @@ export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) =
/>
<div
className={cn(
hasOverlay && !clickOutsideClose ? "cursor-not-allowed" : "",
clickOutsideClose ? "" : "cursor-not-allowed",
"relative ml-8 h-40 w-full rounded",
getOverlayStyle()
overlayStyle
)}>
<div
className={cn(
@@ -133,69 +125,85 @@ export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) =
</div>
</div>
<div className="mt-6 space-y-2">
<FormField
control={form.control}
name="overlay"
render={({ field }) => (
<FormItem>
<FormControl>
<StylingTabs
id="overlay"
options={[
{ value: "none", label: t("common.no_overlay") },
{ value: "light", label: t("common.light_overlay") },
{ value: "dark", label: t("common.dark_overlay") },
]}
defaultSelected={field.value}
onChange={(value) => field.onChange(value)}
label={t("common.overlay_color")}
/>
</FormControl>
</FormItem>
)}
/>
</div>
{hasOverlay && (
<div className="mt-6 space-y-2">
<FormField
control={form.control}
name="clickOutsideClose"
render={({ field }) => (
<FormItem>
<FormLabel className="font-semibold">
{t("common.allow_users_to_exit_by_clicking_outside_the_survey")}
</FormLabel>
<FormControl>
<RadioGroup
disabled={isReadOnly}
onValueChange={(value) => {
field.onChange(value === "allow");
}}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" checked={!field.value} />
<Label
htmlFor="disallow"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.disallow")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" checked={field.value} />
<Label
htmlFor="allow"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.allow")}
</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
{currentPlacement === "center" && (
<>
<div className="mt-6 space-y-2">
<FormField
control={form.control}
name="darkOverlay"
render={({ field }) => (
<FormItem>
<FormLabel className="font-semibold">
{t("environments.workspace.look.centered_modal_overlay_color")}
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(value) => {
field.onChange(value === "darkOverlay");
}}
disabled={isReadOnly}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="lightOverlay" value="lightOverlay" checked={!field.value} />
<Label
htmlFor="lightOverlay"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.light_overlay")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="darkOverlay" value="darkOverlay" checked={field.value} />
<Label
htmlFor="darkOverlay"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.dark_overlay")}
</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="mt-6 space-y-2">
<FormField
control={form.control}
name="clickOutsideClose"
render={({ field }) => (
<FormItem>
<FormLabel className="font-semibold">
{t("common.allow_users_to_exit_by_clicking_outside_the_survey")}
</FormLabel>
<FormControl>
<RadioGroup
disabled={isReadOnly}
onValueChange={(value) => {
field.onChange(value === "allow");
}}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" checked={!field.value} />
<Label
htmlFor="disallow"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.disallow")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" checked={field.value} />
<Label
htmlFor="allow"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.allow")}
</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
</>
)}
<Button className="mt-4 w-fit" size="sm" loading={isSubmitting} disabled={isReadOnly}>
@@ -21,7 +21,7 @@ const baseProject: Project = {
config: { channel: null, industry: null } as any,
placement: "bottomRight",
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
logo: null,
brandColor: null,
highlightBorderColor: null,
@@ -391,7 +391,7 @@ export const ElementFormInput = ({
return (
<div className="w-full">
{label && (
<div className="mb-2 mt-3 flex items-center justify-between">
<div className="mt-3 mb-2 flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
{id === "headline" && currentElement && updateElement && (
<div className="flex items-center space-x-2">
@@ -521,7 +521,7 @@ export const ElementFormInput = ({
return (
<div className="w-full">
{label && (
<div className="mb-2 mt-3 flex items-center justify-between">
<div className="mt-3 mb-2 flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
</div>
)}
@@ -568,7 +568,7 @@ export const ElementFormInput = ({
<div className="h-10 w-full"></div>
<div
ref={highlightContainerRef}
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ${
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent ${
localSurvey.languages?.length > 1 ? "pr-24" : ""
}`}
dir="auto"
@@ -265,7 +265,7 @@ export const BlockCard = ({
</div>
<button
className="opacity-0 hover:cursor-move group-hover:opacity-100"
className="opacity-0 group-hover:opacity-100 hover:cursor-move"
aria-label="Drag to reorder block">
<GripIcon className="h-4 w-4" />
</button>
@@ -69,7 +69,6 @@ export const EndScreenForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!endingCard.headline?.default || endingCard.headline.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
<div>
{endingCard.subheader !== undefined && (
@@ -88,7 +87,6 @@ export const EndScreenForm = ({
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!endingCard.subheader?.default || endingCard.subheader.default.trim() === ""}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>
@@ -141,7 +139,7 @@ export const EndScreenForm = ({
</Label>
</div>
{showEndingCardCTA && (
<div className="mt-4 space-y-4 rounded-md border bg-slate-100 p-4 pt-2">
<div className="mt-4 space-y-4 rounded-md border border-1 bg-slate-100 p-4 pt-2">
<div className="space-y-2">
<ElementFormInput
id="buttonLabel"
@@ -185,7 +183,7 @@ export const EndScreenForm = ({
<div className="group relative">
{/* The highlight container is absolutely positioned behind the input */}
<div
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
dir="auto"
key={highlightedJSX.toString()}>
{highlightedJSX}
@@ -172,7 +172,7 @@ export const FileUploadElementForm = ({
updateElement(elementIdx, { maxSizeInMB: Number.parseInt(e.target.value, 10) });
}}
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
/>
MB
</p>
@@ -27,7 +27,6 @@ interface PictureSelectionFormProps {
isInvalid: boolean;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const PictureSelectionForm = ({
@@ -40,7 +39,6 @@ export const PictureSelectionForm = ({
isInvalid,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: PictureSelectionFormProps): JSX.Element => {
const environmentId = localSurvey.environmentId;
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
@@ -90,7 +88,6 @@ export const PictureSelectionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
autoFocus={!element.headline?.default || element.headline.default.trim() === ""}
/>
<div ref={parent}>
@@ -109,7 +106,6 @@ export const PictureSelectionForm = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
autoFocus={!element.subheader?.default || element.subheader.default.trim() === ""}
/>
</div>
@@ -1,18 +1,17 @@
"use client";
import { useTranslation } from "react-i18next";
import { TOverlay, TPlacement } from "@formbricks/types/common";
import { TPlacement } from "@formbricks/types/common";
import { cn } from "@/lib/cn";
import { Label } from "@/modules/ui/components/label";
import { getPlacementStyle } from "@/modules/ui/components/preview-survey/lib/utils";
import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group";
import { StylingTabs } from "@/modules/ui/components/styling-tabs";
interface TPlacementProps {
currentPlacement: TPlacement;
setCurrentPlacement: (placement: TPlacement) => void;
setOverlay: (overlay: TOverlay) => void;
overlay: TOverlay;
setOverlay: (overlay: string) => void;
overlay: string;
setClickOutsideClose: (clickOutside: boolean) => void;
clickOutsideClose: boolean;
}
@@ -33,15 +32,8 @@ export const Placement = ({
{ name: t("common.bottom_left"), value: "bottomLeft", disabled: false },
{ name: t("common.centered_modal"), value: "center", disabled: false },
];
const hasOverlay = overlay !== "none";
const getOverlayStyle = () => {
if (overlay === "dark") return "bg-slate-700/80";
if (overlay === "light") return "bg-slate-400/50";
return "bg-slate-200";
};
const overlayStyle =
currentPlacement === "center" && overlay === "dark" ? "bg-slate-700/80" : "bg-slate-200";
return (
<>
<div className="flex">
@@ -58,9 +50,9 @@ export const Placement = ({
<div
data-testid="placement-preview"
className={cn(
hasOverlay && !clickOutsideClose ? "cursor-not-allowed" : "",
clickOutsideClose ? "" : "cursor-not-allowed",
"relative ml-8 h-40 w-full rounded",
getOverlayStyle()
overlayStyle
)}>
<div
className={cn(
@@ -69,46 +61,53 @@ export const Placement = ({
)}></div>
</div>
</div>
<div className="mt-6 space-y-2">
<StylingTabs
id="overlay"
options={[
{ value: "none", label: t("common.no_overlay") },
{ value: "light", label: t("common.light_overlay") },
{ value: "dark", label: t("common.dark_overlay") },
]}
defaultSelected={overlay}
onChange={(value) => setOverlay(value)}
label={t("common.overlay_color")}
activeTabClassName="bg-slate-200"
inactiveTabClassName="bg-transparent"
/>
</div>
{hasOverlay && (
<div className="mt-6 space-y-2">
<Label className="font-semibold">
{t("common.allow_users_to_exit_by_clicking_outside_the_survey")}
</Label>
<RadioGroup
onValueChange={(value) => setClickOutsideClose(value === "allow")}
value={clickOutsideClose ? "allow" : "disallow"}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" />
<Label htmlFor="disallow" className="text-slate-900">
{t("common.disallow")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" />
<Label htmlFor="allow" className="text-slate-900">
{t("common.allow")}
</Label>
</div>
</RadioGroup>
</div>
{currentPlacement === "center" && (
<>
<div className="mt-6 space-y-2">
<Label className="font-semibold">
{t("environments.surveys.edit.centered_modal_overlay_color")}
</Label>
<RadioGroup
onValueChange={(overlay) => setOverlay(overlay)}
value={overlay}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="lightOverlay" value="light" />
<Label htmlFor="lightOverlay" className="text-slate-900">
{t("common.light_overlay")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="darkOverlay" value="dark" />
<Label htmlFor="darkOverlay" className="text-slate-900">
{t("common.dark_overlay")}
</Label>
</div>
</RadioGroup>
</div>
<div className="mt-6 space-y-2">
<Label className="font-semibold">
{t("common.allow_users_to_exit_by_clicking_outside_the_survey")}
</Label>
<RadioGroup
onValueChange={(value) => setClickOutsideClose(value === "allow")}
value={clickOutsideClose ? "allow" : "disallow"}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" />
<Label htmlFor="disallow" className="text-slate-900">
{t("common.disallow")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" />
<Label htmlFor="allow" className="text-slate-900">
{t("common.allow")}
</Label>
</div>
</RadioGroup>
</div>
</>
)}
</>
);
@@ -45,7 +45,7 @@ export const RedirectUrlForm = ({ localSurvey, endingCard, updateSurvey }: Redir
<div className="group relative">
{/* The highlight container is absolutely positioned behind the input */}
<div
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
dir="auto"
key={highlightedJSX.toString()}>
{highlightedJSX}
@@ -6,7 +6,7 @@ import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TOverlay, TPlacement } from "@formbricks/types/common";
import { TPlacement } from "@formbricks/types/common";
import { TSurvey, TSurveyProjectOverwrites } from "@formbricks/types/surveys/types";
import { Placement } from "@/modules/survey/editor/components/placement";
import { Label } from "@/modules/ui/components/label";
@@ -27,7 +27,7 @@ export const SurveyPlacementCard = ({
const [open, setOpen] = useState(false);
const { projectOverwrites } = localSurvey ?? {};
const { placement, clickOutsideClose, overlay } = projectOverwrites ?? {};
const { placement, clickOutsideClose, darkOverlay } = projectOverwrites ?? {};
const setProjectOverwrites = (projectOverwrites: TSurveyProjectOverwrites | null) => {
setLocalSurvey({ ...localSurvey, projectOverwrites: projectOverwrites });
@@ -41,7 +41,7 @@ export const SurveyPlacementCard = ({
setProjectOverwrites({
placement: "bottomRight",
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
});
}
}
@@ -56,11 +56,13 @@ export const SurveyPlacementCard = ({
}
};
const handleOverlay = (overlayValue: TOverlay) => {
const handleOverlay = (overlayType: string) => {
const darkOverlay = overlayType === "dark";
if (setProjectOverwrites) {
setProjectOverwrites({
...projectOverwrites,
overlay: overlayValue,
darkOverlay,
});
}
};
@@ -130,7 +132,7 @@ export const SurveyPlacementCard = ({
currentPlacement={placement}
setCurrentPlacement={handlePlacementChange}
setOverlay={handleOverlay}
overlay={overlay ?? "none"}
overlay={darkOverlay ? "dark" : "light"}
setClickOutsideClose={handleClickOutsideClose}
clickOutsideClose={!!clickOutsideClose}
/>
@@ -188,8 +188,6 @@ export const FollowUpModal = ({
subject: defaultValues?.subject ?? t("environments.surveys.edit.follow_ups_modal_action_subject"),
body: defaultValues?.body ?? getSurveyFollowUpActionDefaultBody(t),
attachResponseData: defaultValues?.attachResponseData ?? false,
includeVariables: defaultValues?.includeVariables ?? false,
includeHiddenFields: defaultValues?.includeHiddenFields ?? false,
},
resolver: zodResolver(ZCreateSurveyFollowUpFormSchema),
mode: "onChange",
+1 -1
View File
@@ -47,7 +47,7 @@ const mockProjectPrisma = {
linkSurveyBranding: false,
placement: "bottomRight",
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
segment: null,
surveyClosedMessage: null,
singleUseId: null,
@@ -64,6 +64,7 @@ export interface InputComboboxProps {
showCheckIcon?: boolean;
comboboxClasses?: string;
emptyDropdownText?: string;
disabled?: boolean;
}
// Helper to flatten all options and their children
@@ -87,6 +88,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
showCheckIcon = false,
comboboxClasses,
emptyDropdownText,
disabled = false,
}) => {
const { t } = useTranslation();
const resolvedSearchPlaceholder = searchPlaceholder ?? t("common.search");
@@ -201,6 +203,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
<div
className={cn(
"group/icon flex max-w-[440px] overflow-hidden rounded-md border border-slate-300 hover:border-slate-400",
disabled && "pointer-events-none opacity-50",
comboboxClasses
)}>
{withInput && inputType !== "dropdown" && (
@@ -213,7 +216,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
/>
)}
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu open={open} onOpenChange={(o) => !disabled && setOpen(o)}>
<DropdownMenuTrigger asChild className="z-10">
<div
id={id}
@@ -1,7 +1,7 @@
"use client";
import { ReactNode, useEffect, useRef, useState } from "react";
import { TOverlay, TPlacement } from "@formbricks/types/common";
import { TPlacement } from "@formbricks/types/common";
import { cn } from "@/lib/cn";
import { getPlacementStyle } from "../lib/utils";
@@ -11,7 +11,7 @@ interface ModalProps {
placement: TPlacement;
previewMode: string;
clickOutsideClose: boolean;
overlay: TOverlay;
darkOverlay: boolean;
borderRadius?: number;
background?: string;
}
@@ -22,13 +22,14 @@ export const Modal = ({
placement,
previewMode,
clickOutsideClose,
overlay,
darkOverlay,
borderRadius,
background,
}: ModalProps) => {
const [show, setShow] = useState(true);
const modalRef = useRef<HTMLDivElement | null>(null);
const [windowWidth, setWindowWidth] = useState<number | null>(null);
const [overlayVisible, setOverlayVisible] = useState(placement === "center");
useEffect(() => {
if (typeof window !== "undefined") {
@@ -41,6 +42,10 @@ export const Modal = ({
}
}, []);
useEffect(() => {
setOverlayVisible(placement === "center");
}, [placement]);
const calculateScaling = () => {
if (windowWidth === null) return {};
@@ -79,11 +84,12 @@ export const Modal = ({
const scalingClasses = calculateScaling();
useEffect(() => {
if (!clickOutsideClose) return;
if (!clickOutsideClose || placement !== "center") return;
const handleClickOutside = (e: MouseEvent) => {
const previewBase = document.getElementById("preview-survey-base");
if (
clickOutsideClose &&
modalRef.current &&
previewBase &&
previewBase.contains(e.target as Node) &&
@@ -100,7 +106,7 @@ export const Modal = ({
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [clickOutsideClose]);
}, [clickOutsideClose, placement]);
useEffect(() => {
setShow(isOpen);
@@ -129,8 +135,7 @@ export const Modal = ({
aria-live="assertive"
className={cn(
"relative h-full w-full overflow-hidden rounded-b-md",
overlay === "dark" ? "bg-slate-700/80" : "",
overlay === "light" ? "bg-slate-400/50" : "",
overlayVisible ? (darkOverlay ? "bg-slate-700/80" : "bg-white/50") : "",
"transition-all duration-500 ease-in-out"
)}>
<div
@@ -51,11 +51,11 @@ export const PreviewSurvey = ({
const { projectOverwrites } = survey || {};
const { placement: surveyPlacement } = projectOverwrites || {};
const { overlay: surveyOverlay } = projectOverwrites || {};
const { darkOverlay: surveyDarkOverlay } = projectOverwrites || {};
const { clickOutsideClose: surveyClickOutsideClose } = projectOverwrites || {};
const placement = surveyPlacement || project.placement;
const overlay = surveyOverlay ?? project.overlay;
const darkOverlay = surveyDarkOverlay ?? project.darkOverlay;
const clickOutsideClose = surveyClickOutsideClose ?? project.clickOutsideClose;
const styling: TSurveyStyling | TProjectStyling = useMemo(() => {
@@ -241,7 +241,7 @@ export const PreviewSurvey = ({
isOpen={isModalOpen}
placement={placement}
previewMode="mobile"
overlay={overlay}
darkOverlay={darkOverlay}
clickOutsideClose={clickOutsideClose}
borderRadius={styling?.roundness ?? 8}
background={styling?.cardBackgroundColor?.light}>
@@ -345,7 +345,7 @@ export const PreviewSurvey = ({
isOpen={isModalOpen}
placement={placement}
clickOutsideClose={clickOutsideClose}
overlay={overlay}
darkOverlay={darkOverlay}
previewMode="desktop"
borderRadius={styling.roundness ?? 8}
background={styling.cardBackgroundColor?.light}>
@@ -18,8 +18,6 @@ interface StylingTabsProps<T> {
label?: string;
subLabel?: string;
activeTabClassName?: string;
inactiveTabClassName?: string;
}
export const StylingTabs = <T extends string | number>({
@@ -31,8 +29,6 @@ export const StylingTabs = <T extends string | number>({
tabsContainerClassName,
label,
subLabel,
activeTabClassName,
inactiveTabClassName,
}: StylingTabsProps<T>) => {
const [selectedOption, setSelectedOption] = useState<T | undefined>(defaultSelected);
@@ -61,8 +57,7 @@ export const StylingTabs = <T extends string | number>({
className={cn(
"flex flex-1 cursor-pointer items-center justify-center gap-4 rounded-md py-2 text-center text-sm",
selectedOption === option.value ? "bg-slate-100" : "bg-white",
"focus:ring-brand-dark focus:outline-none focus:ring-2 focus:ring-opacity-50",
selectedOption === option.value ? activeTabClassName : inactiveTabClassName
"focus:ring-brand-dark focus:outline-none focus:ring-2 focus:ring-opacity-50"
)}>
<input
type="radio"
@@ -96,11 +96,11 @@ export const ThemeStylingPreviewSurvey = ({
};
const { placement: surveyPlacement } = projectOverwrites || {};
const { overlay: surveyOverlay } = projectOverwrites || {};
const { darkOverlay: surveyDarkOverlay } = projectOverwrites || {};
const { clickOutsideClose: surveyClickOutsideClose } = projectOverwrites || {};
const placement = surveyPlacement || project.placement;
const overlay = surveyOverlay ?? project.overlay;
const darkOverlay = surveyDarkOverlay ?? project.darkOverlay;
const clickOutsideClose = surveyClickOutsideClose ?? project.clickOutsideClose;
const highlightBorderColor = project.styling.highlightBorderColor?.light;
@@ -162,7 +162,7 @@ export const ThemeStylingPreviewSurvey = ({
isOpen
placement={placement}
clickOutsideClose={clickOutsideClose}
overlay={overlay}
darkOverlay={darkOverlay}
previewMode="desktop"
background={project.styling.cardBackgroundColor?.light}
borderRadius={project.styling.roundness ?? 8}>
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -1,107 +0,0 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
test.describe("Survey Follow-Up Create & Edit", async () => {
// 3 minutes
test.setTimeout(1000 * 60 * 3);
test("Create a follow-up without optional toggles and verify it saves", async ({ page, users }) => {
const user = await users.create();
await user.login();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await test.step("Create a new survey", async () => {
await page.getByText("Start from scratch").click();
await page.getByRole("button", { name: "Create survey", exact: true }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit$/);
});
await test.step("Navigate to Follow-ups tab", async () => {
await page.getByText("Follow-ups").click();
// Verify the empty state is shown
await expect(page.getByText("Send automatic follow-ups")).toBeVisible();
});
await test.step("Create a new follow-up without enabling optional toggles", async () => {
// Click the "New follow-up" button in the empty state
await page.getByRole("button", { name: "New follow-up" }).click();
// Verify the modal is open
await expect(page.getByText("Create a new follow-up")).toBeVisible();
// Fill in the follow-up name
await page.getByPlaceholder("Name your follow-up").fill("Test Follow-Up");
// Leave trigger as default ("Respondent completes survey")
// Leave "Attach response data" toggle OFF (the key scenario for the bug)
// Leave "Include variables" and "Include hidden fields" unchecked
// Click Save
await page.getByRole("button", { name: "Save" }).click();
// The success toast should appear — this was the bug: previously save failed silently
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
expect(successToast).toBeTruthy();
});
await test.step("Verify follow-up appears in the list", async () => {
// After creation, the modal closes and the follow-up should appear in the list
await expect(page.getByText("Test Follow-Up")).toBeVisible();
await expect(page.getByText("Any response")).toBeVisible();
await expect(page.getByText("Send email")).toBeVisible();
});
await test.step("Edit the follow-up and verify it saves", async () => {
// Click on the follow-up to edit it
await page.getByText("Test Follow-Up").click();
// Verify the edit modal opens
await expect(page.getByText("Edit this follow-up")).toBeVisible();
// Change the name
const nameInput = page.getByPlaceholder("Name your follow-up");
await nameInput.clear();
await nameInput.fill("Updated Follow-Up");
// Save the edit
await page.getByRole("button", { name: "Save" }).click();
// The success toast should appear
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
expect(successToast).toBeTruthy();
// Verify the updated name appears in the list
await expect(page.getByText("Updated Follow-Up")).toBeVisible();
});
await test.step("Create a second follow-up with optional toggles enabled", async () => {
// Click "+ New follow-up" button (now in the non-empty state header)
await page.getByRole("button", { name: /New follow-up/ }).click();
// Verify the modal is open
await expect(page.getByText("Create a new follow-up")).toBeVisible();
// Fill in the follow-up name
await page.getByPlaceholder("Name your follow-up").fill("Follow-Up With Data");
// Enable "Attach response data" toggle
await page.locator("#attachResponseData").click();
// Check both optional checkboxes
await page.locator("#includeVariables").click();
await page.locator("#includeHiddenFields").click();
// Click Save
await page.getByRole("button", { name: "Save" }).click();
// The success toast should appear
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
expect(successToast).toBeTruthy();
// Verify both follow-ups appear in the list
await expect(page.getByText("Updated Follow-Up")).toBeVisible();
await expect(page.getByText("Follow-Up With Data")).toBeVisible();
});
});
});
+4 -5
View File
@@ -704,6 +704,10 @@
"example": true,
"type": "boolean"
},
"darkOverlay": {
"example": false,
"type": "boolean"
},
"id": {
"example": "cm6orqtcl000319wj9wb7dltl",
"type": "string"
@@ -712,11 +716,6 @@
"example": true,
"type": "boolean"
},
"overlay": {
"enum": ["none", "light", "dark"],
"example": "none",
"type": "string"
},
"placement": {
"example": "bottomRight",
"type": "string"
+2 -7
View File
@@ -5411,15 +5411,10 @@ components:
type:
- boolean
- "null"
overlay:
darkOverlay:
type:
- string
- boolean
- "null"
enum:
- none
- light
- dark
- null
description: Project specific overwrites
styling:
type:
@@ -1,45 +0,0 @@
-- AlterTable: Add overlay column (enum), migrate data, drop darkOverlay
-- Step 1: Create the SurveyOverlay enum type
CREATE TYPE "SurveyOverlay" AS ENUM ('none', 'light', 'dark');
-- Step 2: Add the new overlay column with the enum type and default value
ALTER TABLE "Project" ADD COLUMN "overlay" "SurveyOverlay" NOT NULL DEFAULT 'none';
-- Step 3: Migrate existing data
-- For center placement: darkOverlay=true -> 'dark', darkOverlay=false -> 'light'
-- For other placements: always 'none' (since overlay wasn't shown before)
UPDATE "Project"
SET "overlay" = CASE
WHEN "placement" = 'center' AND "darkOverlay" = true THEN 'dark'::"SurveyOverlay"
WHEN "placement" = 'center' AND "darkOverlay" = false THEN 'light'::"SurveyOverlay"
ELSE 'none'::"SurveyOverlay"
END;
-- Step 4: Drop the old darkOverlay column
ALTER TABLE "Project" DROP COLUMN "darkOverlay";
-- Step 5: Migrate Survey.projectOverwrites JSON field
-- Only convert darkOverlay -> overlay when placement is explicitly 'center' in projectOverwrites
-- For all other cases, just remove darkOverlay (survey will inherit overlay from project)
-- Case 5a: Survey has placement: 'center' explicitly in projectOverwrites - convert darkOverlay to overlay
UPDATE "Survey"
SET "projectOverwrites" = jsonb_set(
"projectOverwrites"::jsonb - 'darkOverlay',
'{overlay}',
CASE
WHEN ("projectOverwrites"::jsonb->>'darkOverlay') = 'true' THEN '"dark"'::jsonb
ELSE '"light"'::jsonb
END
)
WHERE "projectOverwrites" IS NOT NULL
AND "projectOverwrites"::jsonb ? 'darkOverlay'
AND ("projectOverwrites"::jsonb->>'placement') = 'center';
-- Case 5b: Any remaining surveys with darkOverlay (placement != 'center' or not present) - just remove darkOverlay
-- These surveys will inherit the overlay setting from their project
UPDATE "Survey"
SET "projectOverwrites" = "projectOverwrites"::jsonb - 'darkOverlay'
WHERE "projectOverwrites" IS NOT NULL
AND "projectOverwrites"::jsonb ? 'darkOverlay';
+1 -7
View File
@@ -593,12 +593,6 @@ enum WidgetPlacement {
center
}
enum SurveyOverlay {
none
light
dark
}
/// Main grouping mechanism for resources in Formbricks.
/// Each organization can have multiple projects to separate different applications or products.
///
@@ -627,7 +621,7 @@ model Project {
inAppSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in in-app surveys
placement WidgetPlacement @default(bottomRight)
clickOutsideClose Boolean @default(true)
overlay SurveyOverlay @default(none)
darkOverlay Boolean @default(false)
languages Language[]
/// [Logo]
logo Json?
+1 -2
View File
@@ -2,7 +2,6 @@
import { SurveyStatus, SurveyType } from "@prisma/client";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZOverlay } from "../../types/common";
// eslint-disable-next-line import/no-relative-packages -- Need to import from parent package
import { ZLogo } from "../../types/styling";
import { ZSurveyBlocks } from "../../types/surveys/blocks";
@@ -154,7 +153,7 @@ const ZSurveyBase = z.object({
highlightBorderColor: ZColor.nullish(),
placement: ZPlacement.nullish(),
clickOutsideClose: z.boolean().nullish(),
overlay: ZOverlay.nullish(),
darkOverlay: z.boolean().nullish(),
})
.nullable()
.openapi({
+1 -1
View File
@@ -115,7 +115,7 @@ export const setup = async (
const expiresAt = existingConfig.status.expiresAt;
if (expiresAt && !isNowExpired(new Date(expiresAt))) {
if (expiresAt && isNowExpired(new Date(expiresAt))) {
console.error("🧱 Formbricks - Error state is not expired, skipping initialization");
return okVoid();
}
@@ -86,7 +86,7 @@ export const mockConfig: TConfig = {
id: mockProjectId,
recontactDays: 14,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
styling: {
@@ -262,7 +262,7 @@ describe("api.ts", () => {
id: "project123",
recontactDays: 30,
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
styling: {
@@ -6,7 +6,7 @@ import { addCleanupEventListeners, addEventListeners } from "@/lib/common/event-
import { Logger } from "@/lib/common/logger";
import { handleErrorOnFirstSetup, setup, tearDown } from "@/lib/common/setup";
import { setIsSetup } from "@/lib/common/status";
import { filterSurveys, getIsDebug, isNowExpired } from "@/lib/common/utils";
import { filterSurveys, isNowExpired } from "@/lib/common/utils";
import type * as Utils from "@/lib/common/utils";
import { fetchEnvironmentState } from "@/lib/environment/state";
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
@@ -56,7 +56,6 @@ vi.mock("@/lib/common/utils", async (importOriginal) => {
...originalModule,
filterSurveys: vi.fn(),
isNowExpired: vi.fn(),
getIsDebug: vi.fn(),
};
});
@@ -87,7 +86,6 @@ describe("setup.ts", () => {
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger);
(getIsDebug as unknown as Mock).mockReturnValue(false);
});
afterEach(() => {
@@ -119,8 +117,7 @@ describe("setup.ts", () => {
}
});
test("skips setup if existing config is in error state and not expired (debug mode)", async () => {
(getIsDebug as unknown as Mock).mockReturnValue(true);
test("skips setup if existing config is in error state and not expired", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: "env_123",
@@ -134,7 +131,7 @@ describe("setup.ts", () => {
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
(isNowExpired as unknown as Mock).mockReturnValue(false); // Not expired
(isNowExpired as unknown as Mock).mockReturnValue(true);
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
expect(result.ok).toBe(true);
@@ -143,59 +140,6 @@ describe("setup.ts", () => {
);
});
test("skips initialization if error state is active (not expired)", async () => {
(getIsDebug as unknown as Mock).mockReturnValue(false);
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: "env_123",
appUrl: "https://my.url",
environment: {},
user: { data: {}, expiresAt: null },
status: { value: "error", expiresAt: new Date(Date.now() + 10000) },
}),
resetConfig: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
(isNowExpired as unknown as Mock).mockReturnValue(false); // Time is NOT up
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
expect(result.ok).toBe(true);
// Should NOT fetch environment or user state
expect(fetchEnvironmentState).not.toHaveBeenCalled();
expect(mockConfig.resetConfig).not.toHaveBeenCalled();
});
test("continues initialization if error state is expired", async () => {
(getIsDebug as unknown as Mock).mockReturnValue(false);
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: "env_123",
appUrl: "https://my.url",
environment: { data: { surveys: [] }, expiresAt: new Date() },
user: { data: {}, expiresAt: null },
status: { value: "error", expiresAt: new Date(Date.now() - 10000) },
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
(isNowExpired as unknown as Mock).mockReturnValue(true); // Time IS up
// Mock successful fetch to allow setup to proceed
(fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({
ok: true,
data: { data: { surveys: [] }, expiresAt: new Date() },
});
(filterSurveys as unknown as Mock).mockReturnValue([]);
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
expect(result.ok).toBe(true);
expect(fetchEnvironmentState).toHaveBeenCalled();
});
test("uses existing config if environmentId/appUrl match, checks for expiration sync", async () => {
const mockConfig = {
get: vi.fn().mockReturnValue({
@@ -170,7 +170,7 @@ describe("utils.ts", () => {
id: mockProjectId,
recontactDays: 7, // fallback if survey doesn't have it
clickOutsideClose: false,
overlay: "none",
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
@@ -97,7 +97,7 @@ describe("widget-file", () => {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
},
@@ -163,7 +163,7 @@ describe("widget-file", () => {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
},
@@ -209,7 +209,7 @@ describe("widget-file", () => {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
},
+2 -2
View File
@@ -84,7 +84,7 @@ export const renderWidget = async (
const projectOverwrites = survey.projectOverwrites ?? {};
const clickOutside = projectOverwrites.clickOutsideClose ?? project.clickOutsideClose;
const overlay = projectOverwrites.overlay ?? project.overlay;
const darkOverlay = projectOverwrites.darkOverlay ?? project.darkOverlay;
const placement = projectOverwrites.placement ?? project.placement;
const isBrandingEnabled = project.inAppSurveyBranding;
const formbricksSurveys = await loadFormbricksSurveysExternally();
@@ -110,7 +110,7 @@ export const renderWidget = async (
survey,
isBrandingEnabled,
clickOutside,
overlay,
darkOverlay,
languageCode,
placement,
styling: getStyling(project, survey),
+1 -1
View File
@@ -32,7 +32,7 @@ export type TEnvironmentStateSurvey = Pick<
export type TEnvironmentStateProject = Pick<
Project,
"id" | "recontactDays" | "clickOutsideClose" | "overlay" | "placement" | "inAppSurveyBranding"
"id" | "recontactDays" | "clickOutsideClose" | "darkOverlay" | "placement" | "inAppSurveyBranding"
> & {
styling: TProjectStyling;
};
@@ -33,7 +33,7 @@ export function Headline({
<label htmlFor={elementId} className="text-heading mb-[3px] flex flex-col">
{hasRequiredRule && isQuestionCard && (
<span
className="mb-[3px] text-xs font-normal leading-6 opacity-60"
className="mb-[3px] text-xs leading-6 font-normal opacity-60"
tabIndex={-1}
data-testid="fb__surveys__headline-optional-text-test">
{t("common.required")}
@@ -52,13 +52,11 @@ export function RenderSurvey(props: SurveyContainerProps) {
return null;
}
const hasOverlay = props.overlay && props.overlay !== "none";
return (
<SurveyContainer
mode={props.mode ?? "modal"}
placement={props.placement}
overlay={props.overlay}
darkOverlay={props.darkOverlay}
clickOutside={props.clickOutside}
onClose={close}
isOpen={isOpen}
@@ -66,7 +64,7 @@ export function RenderSurvey(props: SurveyContainerProps) {
{/* @ts-expect-error -- TODO: fix this */}
<Survey
{...props}
clickOutside={hasOverlay ? props.clickOutside : true}
clickOutside={props.placement === "center" ? props.clickOutside : true}
onClose={close}
onFinished={() => {
props.onFinished?.();
@@ -425,10 +425,7 @@ export function Survey({
const firstEndingId = survey.endings.length > 0 ? survey.endings[0].id : undefined;
if (blockId === "start")
return {
nextBlockId: localSurvey.blocks[0]?.id || firstEndingId,
calculatedVariables: {},
};
return { nextBlockId: localSurvey.blocks[0]?.id || firstEndingId, calculatedVariables: {} };
if (!currentBlock) {
console.error(
@@ -679,7 +676,7 @@ export function Survey({
setBlockId(nextBlockId);
} else if (finished) {
// Survey is finished, show the first ending or set to a value > blocks.length
const firstEndingId = localSurvey.endings[0]?.id as string | undefined;
const firstEndingId = localSurvey.endings[0]?.id;
if (firstEndingId) {
setBlockId(firstEndingId);
} else {
@@ -693,7 +690,7 @@ export function Survey({
};
const onBack = (): void => {
let prevBlockId: string | undefined;
let prevBlockId;
// use history if available
if (history.length > 0) {
const newHistory = [...history];
@@ -1,11 +1,11 @@
import { useEffect, useRef } from "preact/hooks";
import { type TOverlay, type TPlacement } from "@formbricks/types/common";
import { type TPlacement } from "@formbricks/types/common";
import { cn } from "@/lib/utils";
interface SurveyContainerProps {
mode: "modal" | "inline";
placement?: TPlacement;
overlay?: TOverlay;
darkOverlay?: boolean;
children: React.ReactNode;
onClose?: () => void;
clickOutside?: boolean;
@@ -16,7 +16,7 @@ interface SurveyContainerProps {
export function SurveyContainer({
mode,
placement = "bottomRight",
overlay = "none",
darkOverlay = false,
children,
onClose,
clickOutside,
@@ -24,16 +24,16 @@ export function SurveyContainer({
dir = "auto",
}: Readonly<SurveyContainerProps>) {
const modalRef = useRef<HTMLDivElement>(null);
const isCenter = placement === "center";
const isModal = mode === "modal";
const hasOverlay = overlay !== "none";
useEffect(() => {
if (!isModal) return;
if (!clickOutside) return;
if (!hasOverlay) return;
if (!isCenter) return;
const handleClickOutside = (e: MouseEvent) => {
if (
clickOutside &&
isOpen &&
modalRef.current &&
!(modalRef.current as HTMLElement).contains(e.target as Node) &&
@@ -42,12 +42,11 @@ export function SurveyContainer({
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [clickOutside, onClose, isModal, isOpen]);
}, [clickOutside, onClose, isCenter, isModal, isOpen]);
const getPlacementStyle = (placement: TPlacement): string => {
switch (placement) {
@@ -81,14 +80,15 @@ export function SurveyContainer({
<div
aria-live="assertive"
className={cn(
hasOverlay ? "pointer-events-auto" : "pointer-events-none",
isModal && "z-999999 fixed inset-0 flex items-end"
isCenter ? "pointer-events-auto" : "pointer-events-none",
isModal && "fixed inset-0 z-999999 flex items-end"
)}>
<div
className={cn(
"relative h-full w-full transition-all duration-500 ease-in-out",
isModal && overlay === "dark" ? "bg-slate-700/80" : "",
isModal && overlay === "light" ? "bg-slate-400/50" : ""
"relative h-full w-full",
!isCenter ? "bg-none transition-all duration-500 ease-in-out" : "",
isModal && isCenter && darkOverlay ? "bg-slate-700/80" : "",
isModal && isCenter && !darkOverlay ? "bg-white/50" : ""
)}>
<div
ref={modalRef}
+4 -4
View File
@@ -17,7 +17,7 @@ export const renderSurveyInline = (props: SurveyContainerProps) => {
export const renderSurvey = (props: SurveyContainerProps) => {
// render SurveyNew
// if survey type is link, we don't pass the placement, overlay, clickOutside, onClose
// if survey type is link, we don't pass the placement, darkOverlay, clickOutside, onClose
const { mode, containerId, languageCode } = props;
@@ -36,9 +36,9 @@ export const renderSurvey = (props: SurveyContainerProps) => {
throw new Error(`renderSurvey: Element with id ${containerId} not found.`);
}
// if survey type is link, we don't pass the placement, overlay, clickOutside, onClose
// if survey type is link, we don't pass the placement, darkOverlay, clickOutside, onClose
if (props.survey.type === "link") {
const { placement, overlay, onClose, clickOutside, ...surveyInlineProps } = props;
const { placement, darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
render(
h(
@@ -52,7 +52,7 @@ export const renderSurvey = (props: SurveyContainerProps) => {
);
} else {
// For non-link surveys, pass placement through so it can be used in StackedCard
const { overlay, onClose, clickOutside, ...surveyInlineProps } = props;
const { darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
render(
h(
-4
View File
@@ -20,10 +20,6 @@ export const ZPlacement = z.enum(["bottomLeft", "bottomRight", "topLeft", "topRi
export type TPlacement = z.infer<typeof ZPlacement>;
export const ZOverlay = z.enum(["none", "light", "dark"]);
export type TOverlay = z.infer<typeof ZOverlay>;
export const ZId = z.string().cuid2();
export const ZUuid = z.string().uuid();
+2 -2
View File
@@ -40,7 +40,7 @@ export interface SurveyInlineProps extends SurveyBaseProps {
export interface SurveyModalProps extends SurveyBaseProps {
clickOutside: boolean;
overlay: "none" | "light" | "dark";
darkOverlay: boolean;
placement: "bottomLeft" | "bottomRight" | "topLeft" | "topRight" | "center";
}
@@ -56,7 +56,7 @@ export interface SurveyContainerProps extends Omit<SurveyBaseProps, "onFileUploa
onOpenExternalURL?: (url: string) => void | Promise<void>;
mode?: "modal" | "inline";
containerId?: string;
overlay?: "none" | "light" | "dark";
darkOverlay?: boolean;
placement?: "bottomLeft" | "bottomRight" | "topLeft" | "topRight" | "center";
action?: string;
singleUseId?: string;
+1 -1
View File
@@ -52,7 +52,7 @@ export const ZJsEnvironmentStateProject = ZProject.pick({
id: true,
recontactDays: true,
clickOutsideClose: true,
overlay: true,
darkOverlay: true,
placement: true,
inAppSurveyBranding: true,
styling: true,
+3 -3
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { ZColor, ZOverlay, ZPlacement } from "./common";
import { ZColor, ZPlacement } from "./common";
import { ZEnvironment } from "./environment";
import { ZBaseStyling, ZLogo } from "./styling";
@@ -65,7 +65,7 @@ export const ZProject = z.object({
config: ZProjectConfig,
placement: ZPlacement,
clickOutsideClose: z.boolean(),
overlay: ZOverlay,
darkOverlay: z.boolean(),
environments: z.array(ZEnvironment),
languages: z.array(ZLanguage),
logo: ZLogo.nullish(),
@@ -84,7 +84,7 @@ export const ZProjectUpdateInput = z.object({
config: ZProjectConfig.optional(),
placement: ZPlacement.optional(),
clickOutsideClose: z.boolean().optional(),
overlay: ZOverlay.optional(),
darkOverlay: z.boolean().optional(),
environments: z.array(ZEnvironment).optional(),
styling: ZProjectStyling.optional(),
logo: ZLogo.optional(),
+4 -4
View File
@@ -1,7 +1,7 @@
import { type ZodIssue, z } from "zod";
import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes";
import { ZColor, ZEndingCardUrl, ZId, ZOverlay, ZPlacement, ZUrl, getZSafeUrl } from "../common";
import { ZColor, ZEndingCardUrl, ZId, ZPlacement, ZUrl, getZSafeUrl } from "../common";
import { ZContactAttributes } from "../contact-attribute";
import { type TI18nString, ZI18nString } from "../i18n";
import { ZLanguage } from "../project";
@@ -228,7 +228,7 @@ export const ZSurveyProjectOverwrites = z.object({
highlightBorderColor: ZColor.nullish(),
placement: ZPlacement.nullish(),
clickOutsideClose: z.boolean().nullish(),
overlay: ZOverlay.nullish(),
darkOverlay: z.boolean().nullish(),
});
export type TSurveyProjectOverwrites = z.infer<typeof ZSurveyProjectOverwrites>;
@@ -2866,7 +2866,7 @@ const validateLogicFallback = (survey: TSurvey, questionIdx: number): z.ZodIssue
}
});
survey.endings.forEach((e: TSurveyEnding) => {
survey.endings.forEach((e) => {
possibleFallbackIds.push(e.id);
});
@@ -3697,7 +3697,7 @@ const validateBlockLogicFallback = (
}
});
survey.endings.forEach((e: TSurveyEnding) => {
survey.endings.forEach((e) => {
possibleFallbackIds.push(e.id);
});