Compare commits

...

4 Commits

6 changed files with 175 additions and 4 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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",

View 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();
});
});
});

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();
}

View File

@@ -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, isNowExpired } from "@/lib/common/utils";
import { filterSurveys, getIsDebug, 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,6 +56,7 @@ vi.mock("@/lib/common/utils", async (importOriginal) => {
...originalModule,
filterSurveys: vi.fn(),
isNowExpired: vi.fn(),
getIsDebug: vi.fn(),
};
});
@@ -86,6 +87,7 @@ 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(() => {
@@ -117,7 +119,8 @@ describe("setup.ts", () => {
}
});
test("skips setup if existing config is in error state and not expired", async () => {
test("skips setup if existing config is in error state and not expired (debug mode)", async () => {
(getIsDebug as unknown as Mock).mockReturnValue(true);
const mockConfig = {
get: vi.fn().mockReturnValue({
environmentId: "env_123",
@@ -131,7 +134,7 @@ describe("setup.ts", () => {
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
(isNowExpired as unknown as Mock).mockReturnValue(true);
(isNowExpired as unknown as Mock).mockReturnValue(false); // Not expired
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
expect(result.ok).toBe(true);
@@ -140,6 +143,59 @@ 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({