mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-07 10:32:50 -06:00
Compare commits
3 Commits
fix/7165-w
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04220902b4 | ||
|
|
4649a2de3e | ||
|
|
56ce05fb94 |
@@ -1,13 +1,15 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } 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 { 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";
|
||||
@@ -31,6 +33,38 @@ 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,
|
||||
@@ -113,6 +147,11 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const validationResult = validateResponse(response, survey, inputValidation.data);
|
||||
if (validationResult) {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
// update response with quota evaluation
|
||||
let updatedResponse;
|
||||
try {
|
||||
|
||||
@@ -6,12 +6,14 @@ 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";
|
||||
@@ -33,6 +35,27 @@ 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;
|
||||
@@ -123,6 +146,11 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const validationResult = validateResponse(responseInputData, survey);
|
||||
if (validationResult) {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
let response: TResponseWithQuotaFull;
|
||||
try {
|
||||
const meta: TResponseInput["meta"] = {
|
||||
|
||||
@@ -8,10 +8,7 @@ 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/v2/management/responses/lib/validation";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||
@@ -149,6 +146,7 @@ export const PUT = withV1ApiWrapper({
|
||||
result.survey.blocks,
|
||||
responseUpdate.data,
|
||||
responseUpdate.language ?? "en",
|
||||
responseUpdate.finished,
|
||||
result.survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -7,10 +7,7 @@ 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/v2/management/responses/lib/validation";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import {
|
||||
@@ -158,6 +155,7 @@ export const POST = withV1ApiWrapper({
|
||||
surveyResult.survey.blocks,
|
||||
responseInput.data,
|
||||
responseInput.language ?? "en",
|
||||
responseInput.finished,
|
||||
surveyResult.survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ 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";
|
||||
@@ -106,6 +107,23 @@ 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"] = {
|
||||
|
||||
@@ -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 "./validation";
|
||||
} from "@/modules/api/lib/validation";
|
||||
|
||||
const mockTransformQuestionsToBlocks = vi.fn();
|
||||
const mockGetElementsFromBlocks = vi.fn();
|
||||
@@ -95,7 +95,7 @@ describe("validateResponseData", () => {
|
||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||
mockValidateBlockResponses.mockReturnValue({});
|
||||
|
||||
validateResponseData([], mockResponseData, "en", mockQuestions);
|
||||
validateResponseData([], mockResponseData, "en", true, mockQuestions);
|
||||
|
||||
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
|
||||
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
|
||||
@@ -105,15 +105,15 @@ describe("validateResponseData", () => {
|
||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||
mockValidateBlockResponses.mockReturnValue({});
|
||||
|
||||
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
|
||||
validateResponseData(mockBlocks, mockResponseData, "en", true, mockQuestions);
|
||||
|
||||
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return null when both blocks and questions are empty", () => {
|
||||
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
|
||||
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
|
||||
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
|
||||
expect(validateResponseData([], mockResponseData, "en", true, [])).toBeNull();
|
||||
expect(validateResponseData(null, mockResponseData, "en", true, [])).toBeNull();
|
||||
expect(validateResponseData(undefined, mockResponseData, "en", true, null)).toBeNull();
|
||||
});
|
||||
|
||||
test("should use default language code", () => {
|
||||
@@ -124,15 +124,36 @@ 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("formatValidationErrorsForApi", () => {
|
||||
describe("formatValidationErrorsForV2Api", () => {
|
||||
test("should convert error map to V2 API format", () => {
|
||||
const errorMap: TValidationErrorMap = {
|
||||
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length required" }],
|
||||
};
|
||||
|
||||
const result = formatValidationErrorsForApi(errorMap);
|
||||
const result = formatValidationErrorsForV2Api(errorMap);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -151,7 +172,7 @@ describe("formatValidationErrorsForApi", () => {
|
||||
],
|
||||
};
|
||||
|
||||
const result = formatValidationErrorsForApi(errorMap);
|
||||
const result = formatValidationErrorsForV2Api(errorMap);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].field).toBe("response.data.element1");
|
||||
@@ -164,7 +185,7 @@ describe("formatValidationErrorsForApi", () => {
|
||||
element2: [{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" }],
|
||||
};
|
||||
|
||||
const result = formatValidationErrorsForApi(errorMap);
|
||||
const result = formatValidationErrorsForV2Api(errorMap);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].field).toBe("response.data.element1");
|
||||
@@ -10,17 +10,20 @@ 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
|
||||
@@ -37,22 +40,26 @@ export const validateResponseData = (
|
||||
}
|
||||
|
||||
// Extract elements from blocks
|
||||
const elements = getElementsFromBlocks(blocksToUse);
|
||||
const allElements = getElementsFromBlocks(blocksToUse);
|
||||
|
||||
// Validate all elements
|
||||
const errorMap = validateBlockResponses(elements, responseData, languageCode);
|
||||
// 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);
|
||||
|
||||
// Return null if no errors (validation passed), otherwise return error map
|
||||
return Object.keys(errorMap).length === 0 ? null : errorMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts validation error map to API error response format (V2)
|
||||
* Converts validation error map to V2 API error response format
|
||||
*
|
||||
* @param errorMap - Validation error map from validateResponseData
|
||||
* @returns API error response details
|
||||
* @returns V2 API error response details
|
||||
*/
|
||||
export const formatValidationErrorsForApi = (errorMap: TValidationErrorMap) => {
|
||||
export const formatValidationErrorsForV2Api = (errorMap: TValidationErrorMap) => {
|
||||
const details: ApiErrorDetails = [];
|
||||
|
||||
for (const [elementId, errors] of Object.entries(errorMap)) {
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -15,7 +16,6 @@ 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,6 +198,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
questionsResponse.data.blocks,
|
||||
body.data,
|
||||
body.language ?? "en",
|
||||
body.finished,
|
||||
questionsResponse.data.questions
|
||||
);
|
||||
|
||||
@@ -206,7 +207,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: formatValidationErrorsForApi(validationErrors),
|
||||
details: formatValidationErrorsForV2Api(validationErrors),
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -13,7 +14,6 @@ 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,6 +134,7 @@ export const POST = async (request: Request) =>
|
||||
surveyQuestions.data.blocks,
|
||||
body.data,
|
||||
body.language ?? "en",
|
||||
body.finished,
|
||||
surveyQuestions.data.questions
|
||||
);
|
||||
|
||||
@@ -142,7 +143,7 @@ export const POST = async (request: Request) =>
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: formatValidationErrorsForApi(validationErrors),
|
||||
details: formatValidationErrorsForV2Api(validationErrors),
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
|
||||
@@ -69,6 +69,7 @@ export const EndScreenForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!endingCard.headline?.default || endingCard.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
<div>
|
||||
{endingCard.subheader !== undefined && (
|
||||
@@ -87,6 +88,7 @@ export const EndScreenForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!endingCard.subheader?.default || endingCard.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ interface PictureSelectionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const PictureSelectionForm = ({
|
||||
@@ -39,6 +40,7 @@ export const PictureSelectionForm = ({
|
||||
isInvalid,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: PictureSelectionFormProps): JSX.Element => {
|
||||
const environmentId = localSurvey.environmentId;
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
@@ -88,6 +90,7 @@ export const PictureSelectionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
autoFocus={!element.headline?.default || element.headline.default.trim() === ""}
|
||||
/>
|
||||
<div ref={parent}>
|
||||
@@ -106,6 +109,7 @@ export const PictureSelectionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
autoFocus={!element.subheader?.default || element.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -188,6 +188,8 @@ 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",
|
||||
|
||||
107
apps/web/playwright/survey-follow-up.spec.ts
Normal file
107
apps/web/playwright/survey-follow-up.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user