mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-20 19:30:41 -05:00
Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1e5ce6270 | |||
| 86cc8fb8ff | |||
| ee56cc10e7 | |||
| 7c47299775 | |||
| 92f4f04f7c | |||
| e072a0e889 | |||
| 4723a428e7 | |||
| d7692a1b76 | |||
| a08f2db40c | |||
| 48eb4fe705 | |||
| 26a2d50d45 | |||
| da5d9e27e1 | |||
| 8e2934d7bb | |||
| 799b86801d | |||
| 9d77b808d0 | |||
| 007d996870 | |||
| 9f59d7a967 | |||
| b9c647ef62 | |||
| 7770d43f9a | |||
| 615aa6aaad | |||
| f57ca755f4 | |||
| fd37b978c0 | |||
| d40ce9ce84 | |||
| 21c63bc400 | |||
| 77722aa638 | |||
| 2a9897370e | |||
| 5e85347bf5 | |||
| e4a9d28b4b | |||
| b79703f87e | |||
| 567cc4b893 | |||
| d9b37496fc | |||
| 87a06c846a | |||
| c5e02a597d | |||
| 1fec0ca7a6 | |||
| ae165eac87 | |||
| 7de5fdc383 | |||
| 3e61d31041 | |||
| d7e537f699 | |||
| 1e6c7609b6 | |||
| 59438d9afe | |||
| e234ed78cf | |||
| 74b168d727 | |||
| 2ed2da61cd | |||
| 729f269d4e | |||
| 41776d0001 | |||
| a2a6870a21 | |||
| 3ab62968e5 | |||
| d4f7f0f35d | |||
| a10cd0cb47 | |||
| 45100673f1 | |||
| 35f53769a5 | |||
| 22ad78a187 | |||
| 67076c4b4c | |||
| 74bfeb132e | |||
| 562b4047ae | |||
| 4791018546 | |||
| be1e546729 | |||
| 5bad0da477 | |||
| 9c776c5e4e | |||
| c50b46f715 | |||
| ce0a0573be | |||
| 3e27143ab1 | |||
| 018e2883ff | |||
| 85fb7ca956 | |||
| 2258699156 | |||
| b1a7b929bd | |||
| fded9a3bad | |||
| 4d84468269 | |||
| e6e010e801 | |||
| 8ced882406 | |||
| f7d462cc7f | |||
| f3d679d087 | |||
| c79a600efc | |||
| 7a8da3b84b | |||
| 4b2d48397d | |||
| 3ea81dc7c1 | |||
| d9b6b550a9 | |||
| 56a6ba08ba | |||
| 1ba55ff66c | |||
| 0cf621d76c | |||
| 3dc615fdc0 | |||
| 7157b17901 | |||
| 82c26941e4 | |||
| 591d5fa3d4 | |||
| 211bca1bd8 | |||
| 5a20839c5b | |||
| 85743bd3d0 | |||
| 335ec02361 | |||
| 7918523957 | |||
| 3b5fe4cb94 | |||
| 6bbd5ec7ef | |||
| c9542dcf79 | |||
| 4277a9dc34 | |||
| b1da63e47d | |||
| 8c05154a86 | |||
| 45122de652 | |||
| 2180bf98ba | |||
| 2d4a94721b | |||
| b2b97c8bed | |||
| f349f7199d | |||
| e7d8803a13 | |||
| 53a9b218bc | |||
| c618e7d473 | |||
| 3d0f703ae1 | |||
| 33eadaaa7b | |||
| 452617529c | |||
| 5951eea618 | |||
| e314feb416 | |||
| 0910b0f1a7 | |||
| 10ba42eb31 | |||
| 04f1e17e23 | |||
| 4642cc60c9 | |||
| 49fa5c587c | |||
| 4f9b48b5e5 | |||
| 80789327d0 | |||
| 38108a32d1 | |||
| ce4b64da0e | |||
| 9790b071d7 | |||
| 1f5ba0e60e | |||
| b502bbc91e | |||
| 6772ac7c20 |
+816
-259
File diff suppressed because it is too large
Load Diff
+51
-71
@@ -48,29 +48,6 @@ type ElementFilterComboBoxProps = {
|
||||
fieldId?: string;
|
||||
};
|
||||
|
||||
// Helper function to check if multiple selection is allowed
|
||||
const checkIsMultiple = (
|
||||
type: TSurveyElementTypeEnum | Omit<OptionsType, OptionsType.ELEMENTS> | undefined,
|
||||
filterValue: string | undefined
|
||||
): boolean => {
|
||||
const isMultiSelectType =
|
||||
type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
|
||||
type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
||||
type === TSurveyElementTypeEnum.PictureSelection;
|
||||
const isNPSIncludesEither = type === TSurveyElementTypeEnum.NPS && filterValue === "Includes either";
|
||||
return isMultiSelectType || isNPSIncludesEither;
|
||||
};
|
||||
|
||||
// Helper function to check if combo box should be disabled
|
||||
const checkIsDisabledComboBox = (
|
||||
type: TSurveyElementTypeEnum | Omit<OptionsType, OptionsType.ELEMENTS> | undefined,
|
||||
filterValue: string | undefined
|
||||
): boolean => {
|
||||
const isNPSOrRating = type === TSurveyElementTypeEnum.NPS || type === TSurveyElementTypeEnum.Rating;
|
||||
const isSubmittedOrSkipped = filterValue === "Submitted" || filterValue === "Skipped";
|
||||
return isNPSOrRating && isSubmittedOrSkipped;
|
||||
};
|
||||
|
||||
export const ElementFilterComboBox = ({
|
||||
filterComboBoxOptions,
|
||||
filterComboBoxValue,
|
||||
@@ -90,7 +67,13 @@ export const ElementFilterComboBox = ({
|
||||
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
|
||||
const isMultiple = checkIsMultiple(type, filterValue);
|
||||
// Check if multiple selection is allowed
|
||||
const isMultiSelectType =
|
||||
type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
|
||||
type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
||||
type === TSurveyElementTypeEnum.PictureSelection;
|
||||
const isNPSIncludesEither = type === TSurveyElementTypeEnum.NPS && filterValue === "Includes either";
|
||||
const isMultiple = isMultiSelectType || isNPSIncludesEither;
|
||||
|
||||
// Filter out already selected options for multi-select
|
||||
const options = useMemo(() => {
|
||||
@@ -102,7 +85,10 @@ export const ElementFilterComboBox = ({
|
||||
});
|
||||
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue]);
|
||||
|
||||
const isDisabledComboBox = checkIsDisabledComboBox(type, filterValue);
|
||||
// Disable combo box for NPS/Rating when Submitted/Skipped
|
||||
const isNPSOrRating = type === TSurveyElementTypeEnum.NPS || type === TSurveyElementTypeEnum.Rating;
|
||||
const isSubmittedOrSkipped = filterValue === "Submitted" || filterValue === "Skipped";
|
||||
const isDisabledComboBox = isNPSOrRating && isSubmittedOrSkipped;
|
||||
|
||||
// Check if this is a text input field (URL meta field)
|
||||
const isTextInputField = type === OptionsType.META && fieldId === "url";
|
||||
@@ -131,56 +117,12 @@ export const ElementFilterComboBox = ({
|
||||
};
|
||||
|
||||
const isComboBoxDisabled = disabled || isDisabledComboBox || !filterValue;
|
||||
const ChevronIcon = open ? ChevronUp : ChevronDown;
|
||||
|
||||
// Render filter options dropdown
|
||||
const renderFilterOptionsDropdown = () => {
|
||||
if (!filterOptions || filterOptions.length <= 1) {
|
||||
return (
|
||||
<div className="flex h-9 max-w-fit items-center rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600">
|
||||
<p className="mr-1 max-w-[50px] truncate sm:max-w-[100px]">{filterValue}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
if (value) setOpen(false);
|
||||
}}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
"flex h-9 max-w-fit items-center justify-between gap-2 rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||
disabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
|
||||
)}>
|
||||
{filterValue ? (
|
||||
<p className="max-w-[50px] truncate sm:max-w-[80px]">{filterValue}</p>
|
||||
) : (
|
||||
<p className="text-slate-400">{t("common.select")}...</p>
|
||||
)}
|
||||
{filterOptions.length > 1 && <ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" />}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-white">
|
||||
{filterOptions.map((o, index) => {
|
||||
const optionValue = getOptionValue(o);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${optionValue}-${index}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onChangeFilterValue(optionValue)}>
|
||||
{optionValue}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const handleOpenDropdown = () => {
|
||||
if (isComboBoxDisabled) return;
|
||||
setOpen(true);
|
||||
};
|
||||
const ChevronIcon = open ? ChevronUp : ChevronDown;
|
||||
|
||||
// Helper to filter out a specific value from the array
|
||||
const getFilteredValues = (valueToRemove: string): string[] => {
|
||||
@@ -239,7 +181,45 @@ export const ElementFilterComboBox = ({
|
||||
|
||||
return (
|
||||
<div className="inline-flex h-fit w-full flex-row rounded-md border border-slate-300 hover:border-slate-400">
|
||||
{renderFilterOptionsDropdown()}
|
||||
{filterOptions && filterOptions.length <= 1 ? (
|
||||
<div className="flex h-9 max-w-fit items-center rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600">
|
||||
<p className="mr-1 max-w-[50px] truncate sm:max-w-[100px]">{filterValue}</p>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
if (value) setOpen(false);
|
||||
}}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
"flex h-9 max-w-fit items-center justify-between gap-2 rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||
disabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
|
||||
)}>
|
||||
{filterValue ? (
|
||||
<p className="max-w-[50px] truncate sm:max-w-[80px]">{filterValue}</p>
|
||||
) : (
|
||||
<p className="text-slate-400">{t("common.select")}...</p>
|
||||
)}
|
||||
{filterOptions && filterOptions.length > 1 && (
|
||||
<ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-white">
|
||||
{filterOptions?.map((o, index) => {
|
||||
const optionValue = getOptionValue(o);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${optionValue}-${index}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onChangeFilterValue(optionValue)}>
|
||||
{optionValue}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{isTextInputField ? (
|
||||
<Input
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
import { IntegrationType } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { getCacheService } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { sendTelemetryEvents } from "./telemetry";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/cache");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
organization: {
|
||||
findFirst: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
user: { count: vi.fn() },
|
||||
team: { count: vi.fn() },
|
||||
project: { count: vi.fn() },
|
||||
survey: { count: vi.fn() },
|
||||
response: {
|
||||
count: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
display: { count: vi.fn() },
|
||||
contact: { count: vi.fn() },
|
||||
segment: { count: vi.fn() },
|
||||
integration: { findMany: vi.fn() },
|
||||
account: { findMany: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
S3_BUCKET_NAME: "my-bucket",
|
||||
PROMETHEUS_ENABLED: true,
|
||||
RECAPTCHA_SITE_KEY: "site-key",
|
||||
RECAPTCHA_SECRET_KEY: "secret-key",
|
||||
GITHUB_ID: "github-id",
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
const fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const mockCacheService = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
tryLock: vi.fn(),
|
||||
del: vi.fn(),
|
||||
};
|
||||
|
||||
describe("sendTelemetryEvents", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.useFakeTimers();
|
||||
// Set a fixed time far in the past to ensure we can always send telemetry
|
||||
vi.setSystemTime(new Date("2024-01-01T00:00:00.000Z"));
|
||||
|
||||
// Setup default cache behavior
|
||||
vi.mocked(getCacheService).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockCacheService as any,
|
||||
});
|
||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
|
||||
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
|
||||
mockCacheService.get.mockResolvedValue({ ok: true, data: null }); // No last sent time
|
||||
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
|
||||
|
||||
// Setup default prisma behavior
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
|
||||
id: "org-123",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
} as any);
|
||||
|
||||
// Mock raw SQL query for counts (batched query)
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{
|
||||
organizationCount: BigInt(1),
|
||||
userCount: BigInt(5),
|
||||
teamCount: BigInt(2),
|
||||
projectCount: BigInt(3),
|
||||
surveyCount: BigInt(10),
|
||||
inProgressSurveyCount: BigInt(4),
|
||||
completedSurveyCount: BigInt(6),
|
||||
responseCountAllTime: BigInt(100),
|
||||
responseCountSinceLastUpdate: BigInt(10),
|
||||
displayCount: BigInt(50),
|
||||
contactCount: BigInt(20),
|
||||
segmentCount: BigInt(4),
|
||||
newestResponseAt: new Date("2024-01-01T00:00:00.000Z"),
|
||||
},
|
||||
] as any);
|
||||
|
||||
// Mock other queries
|
||||
vi.mocked(prisma.integration.findMany).mockResolvedValue([{ type: IntegrationType.notion }] as any);
|
||||
vi.mocked(prisma.account.findMany).mockResolvedValue([{ provider: "github" }] as any);
|
||||
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("should send telemetry successfully when conditions are met", async () => {
|
||||
await sendTelemetryEvents();
|
||||
|
||||
// Check lock acquisition
|
||||
expect(mockCacheService.tryLock).toHaveBeenCalledWith(
|
||||
"telemetry_lock",
|
||||
"locked",
|
||||
60 * 1000 // 1 minute TTL
|
||||
);
|
||||
|
||||
// Check data gathering
|
||||
expect(prisma.organization.findFirst).toHaveBeenCalled();
|
||||
expect(prisma.$queryRaw).toHaveBeenCalled();
|
||||
|
||||
// Check fetch call
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(payload.organizationCount).toBe(1);
|
||||
expect(payload.userCount).toBe(5);
|
||||
expect(payload.integrations.notion).toBe(true);
|
||||
expect(payload.sso.github).toBe(true);
|
||||
|
||||
// Check cache update (no TTL parameter)
|
||||
expect(mockCacheService.set).toHaveBeenCalledWith("telemetry_last_sent_ts", expect.any(String));
|
||||
|
||||
// Check lock release
|
||||
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
|
||||
});
|
||||
|
||||
test("should skip if in-memory check fails", async () => {
|
||||
// Run once to set nextTelemetryCheck
|
||||
await sendTelemetryEvents();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Run again immediately (should fail in-memory check)
|
||||
await sendTelemetryEvents();
|
||||
|
||||
expect(getCacheService).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should skip if Redis last sent time is recent", async () => {
|
||||
// Mock last sent time as recent
|
||||
const recentTime = Date.now() - 1000 * 60 * 60; // 1 hour ago
|
||||
mockCacheService.get.mockResolvedValue({ ok: true, data: String(recentTime) });
|
||||
|
||||
await sendTelemetryEvents();
|
||||
|
||||
expect(mockCacheService.tryLock).not.toHaveBeenCalled(); // No lock attempt
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should skip if lock cannot be acquired", async () => {
|
||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: false }); // Lock not acquired
|
||||
|
||||
await sendTelemetryEvents();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(mockCacheService.del).not.toHaveBeenCalled(); // Shouldn't try to delete lock we didn't acquire
|
||||
});
|
||||
|
||||
test("should handle cache service failure gracefully", async () => {
|
||||
vi.mocked(getCacheService).mockResolvedValue({
|
||||
ok: false,
|
||||
error: new Error("Cache error"),
|
||||
} as any);
|
||||
|
||||
await sendTelemetryEvents();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
// Should verify that nextTelemetryCheck was updated, but it's a module variable.
|
||||
// We can infer it by running again and checking calls
|
||||
vi.clearAllMocks();
|
||||
await sendTelemetryEvents();
|
||||
expect(getCacheService).not.toHaveBeenCalled(); // Should be blocked by in-memory check
|
||||
});
|
||||
|
||||
test("should handle telemetry send failure and apply cooldown", async () => {
|
||||
// Reset module to clear nextTelemetryCheck state from previous tests
|
||||
vi.resetModules();
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
// Ensure we can acquire lock by setting last sent time far in the past
|
||||
const oldTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
|
||||
mockCacheService.get.mockResolvedValue({ ok: true, data: String(oldTime) });
|
||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
|
||||
|
||||
// Make fetch fail to trigger the catch block
|
||||
const networkError = new Error("Network error");
|
||||
fetchMock.mockRejectedValue(networkError);
|
||||
|
||||
await freshSendTelemetryEvents();
|
||||
|
||||
// Verify lock was acquired
|
||||
expect(mockCacheService.tryLock).toHaveBeenCalledWith("telemetry_lock", "locked", 60 * 1000);
|
||||
|
||||
// The error should be caught in the inner catch block
|
||||
// The actual implementation logs as warning, not error
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: networkError,
|
||||
message: "Network error",
|
||||
}),
|
||||
"Failed to send telemetry - applying 1h cooldown"
|
||||
);
|
||||
|
||||
// Lock should be released in finally block
|
||||
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
|
||||
|
||||
// Cache should not be updated on failure
|
||||
expect(mockCacheService.set).not.toHaveBeenCalled();
|
||||
|
||||
// Verify cooldown: run again immediately (should be blocked by in-memory check)
|
||||
vi.clearAllMocks();
|
||||
mockCacheService.get.mockResolvedValue({ ok: true, data: null });
|
||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true });
|
||||
await freshSendTelemetryEvents();
|
||||
expect(getCacheService).not.toHaveBeenCalled(); // Should be blocked by in-memory check
|
||||
});
|
||||
|
||||
test("should skip if no organization exists", async () => {
|
||||
// Reset module to clear nextTelemetryCheck state from previous tests
|
||||
vi.resetModules();
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
// Ensure we can acquire lock by setting last sent time far in the past
|
||||
const oldTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
|
||||
|
||||
// Re-setup mocks after resetModules
|
||||
vi.mocked(getCacheService).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockCacheService as any,
|
||||
});
|
||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
|
||||
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
|
||||
mockCacheService.get.mockResolvedValue({ ok: true, data: String(oldTime) });
|
||||
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
|
||||
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
|
||||
|
||||
await freshSendTelemetryEvents();
|
||||
|
||||
// sendTelemetry returns early when no org exists
|
||||
// Since it returns (not throws), the try block completes successfully
|
||||
// Then cache.set is called, and finally block executes
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
// Verify lock was acquired (prerequisite for finally block to execute)
|
||||
expect(mockCacheService.tryLock).toHaveBeenCalledWith("telemetry_lock", "locked", 60 * 1000);
|
||||
|
||||
// Lock should be released in finally block
|
||||
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
|
||||
|
||||
// Note: The current implementation calls cache.set even when no org exists
|
||||
// This might be a bug, but we test the actual behavior
|
||||
expect(mockCacheService.set).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,273 +0,0 @@
|
||||
import { IntegrationType } from "@prisma/client";
|
||||
import { createHash } from "node:crypto";
|
||||
import { type CacheKey, getCacheService } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { env } from "@/lib/env";
|
||||
import packageJson from "@/package.json";
|
||||
|
||||
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const TELEMETRY_LOCK_KEY = "telemetry_lock" as CacheKey;
|
||||
const TELEMETRY_LAST_SENT_KEY = "telemetry_last_sent_ts" as CacheKey;
|
||||
|
||||
/**
|
||||
* In-memory timestamp for the next telemetry check.
|
||||
* This is a fast, process-local check to avoid unnecessary Redis calls.
|
||||
* Updated after each check to prevent redundant executions.
|
||||
*/
|
||||
let nextTelemetryCheck = 0;
|
||||
|
||||
/**
|
||||
* Sends telemetry events to Formbricks Enterprise endpoint.
|
||||
* Uses a three-layer check system to prevent duplicate submissions:
|
||||
* 1. In-memory check (fast, process-local)
|
||||
* 2. Redis check (shared across instances, persists across restarts)
|
||||
* 3. Distributed lock (prevents concurrent execution in multi-instance deployments)
|
||||
*/
|
||||
export const sendTelemetryEvents = async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// ============================================================
|
||||
// CHECK 1: In-Memory Check (Fast Path)
|
||||
// ============================================================
|
||||
// Purpose: Quick process-local check to avoid Redis calls if we recently checked.
|
||||
// How it works: If current time is before nextTelemetryCheck, skip entirely.
|
||||
// This is updated after each successful check or failure to prevent spam.
|
||||
if (now < nextTelemetryCheck) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHECK 2: Redis Check (Shared State)
|
||||
// ============================================================
|
||||
// Purpose: Check if telemetry was sent recently by ANY instance (shared across cluster).
|
||||
// This persists across restarts and works in multi-instance deployments.
|
||||
|
||||
const cacheServiceResult = await getCacheService();
|
||||
if (!cacheServiceResult.ok) {
|
||||
// Redis unavailable: Fallback to in-memory cooldown to avoid spamming.
|
||||
// Wait 1 hour before trying again. This prevents hammering Redis when it's down.
|
||||
nextTelemetryCheck = now + 60 * 60 * 1000;
|
||||
return;
|
||||
}
|
||||
const cache = cacheServiceResult.data;
|
||||
|
||||
// Get the timestamp of when telemetry was last sent (from any instance).
|
||||
const lastSentResult = await cache.get(TELEMETRY_LAST_SENT_KEY);
|
||||
const lastSentStr = lastSentResult.ok && lastSentResult.data ? (lastSentResult.data as string) : null;
|
||||
const lastSent = lastSentStr ? Number.parseInt(lastSentStr, 10) : 0;
|
||||
|
||||
// If less than 24 hours have passed since last telemetry, skip.
|
||||
// Update in-memory check to match remaining time for fast-path optimization.
|
||||
if (now - lastSent < TELEMETRY_INTERVAL_MS) {
|
||||
nextTelemetryCheck = lastSent + TELEMETRY_INTERVAL_MS;
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHECK 3: Distributed Lock (Prevent Concurrent Execution)
|
||||
// ============================================================
|
||||
// Purpose: Ensure only ONE instance executes telemetry at a time in a cluster.
|
||||
// How it works:
|
||||
// - Uses Redis SET NX (only set if not exists) for atomic lock acquisition
|
||||
// - Lock expires after 1 minute (TTL) to prevent deadlocks if instance crashes
|
||||
// - If lock exists, another instance is already running telemetry, so we exit
|
||||
// - Lock is released in finally block after telemetry completes or fails
|
||||
const lockResult = await cache.tryLock(TELEMETRY_LOCK_KEY, "locked", 60 * 1000); // 1 minute TTL
|
||||
|
||||
if (!lockResult.ok || !lockResult.data) {
|
||||
// Lock acquisition failed or already held by another instance.
|
||||
// Exit silently - the other instance will handle telemetry.
|
||||
// No need to update nextTelemetryCheck here since we didn't execute.
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EXECUTION: Send Telemetry
|
||||
// ============================================================
|
||||
// We've passed all checks and acquired the lock. Now execute telemetry.
|
||||
try {
|
||||
await sendTelemetry(lastSent);
|
||||
|
||||
// Success: Update Redis with current timestamp so other instances know telemetry was sent.
|
||||
// No TTL - persists indefinitely to support low-volume instances (responses every few days/weeks).
|
||||
await cache.set(TELEMETRY_LAST_SENT_KEY, now.toString());
|
||||
|
||||
// Update in-memory check to prevent this instance from checking again for 24h.
|
||||
nextTelemetryCheck = now + TELEMETRY_INTERVAL_MS;
|
||||
} catch (e) {
|
||||
// Log as warning since telemetry is non-essential
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
logger.warn(
|
||||
{ error: e, message: errorMessage, lastSent, now },
|
||||
"Failed to send telemetry - applying 1h cooldown"
|
||||
);
|
||||
|
||||
// Failure cooldown: Prevent retrying immediately to avoid hammering the endpoint.
|
||||
// Wait 1 hour before allowing this instance to try again.
|
||||
// Note: Other instances can still try (they'll hit the lock or Redis check).
|
||||
nextTelemetryCheck = now + 60 * 60 * 1000;
|
||||
} finally {
|
||||
// Always release the lock, even if telemetry failed.
|
||||
// This allows other instances to retry if this one failed.
|
||||
await cache.del([TELEMETRY_LOCK_KEY]);
|
||||
}
|
||||
} catch (error) {
|
||||
// Catch-all for any unexpected errors in the wrapper logic (cache failures, lock issues, etc.)
|
||||
// Log as warning since telemetry is non-essential functionality
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(
|
||||
{ error, message: errorMessage, timestamp: Date.now() },
|
||||
"Unexpected error in sendTelemetryEvents wrapper - telemetry check skipped"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gathers telemetry data and sends it to Formbricks Enterprise endpoint.
|
||||
* @param lastSent - Timestamp of last telemetry send (used to calculate incremental metrics)
|
||||
*/
|
||||
const sendTelemetry = async (lastSent: number) => {
|
||||
// Get the oldest organization to generate a stable, anonymized instance ID.
|
||||
// Using the oldest org ensures the ID doesn't change over time.
|
||||
const oldestOrg = await prisma.organization.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true, createdAt: true },
|
||||
});
|
||||
|
||||
if (!oldestOrg) return; // No organization exists, nothing to report
|
||||
const instanceId = createHash("sha256").update(oldestOrg.id).digest("hex");
|
||||
|
||||
// Optimize database queries to reduce connection pool usage:
|
||||
// Instead of 15 parallel queries (which could exhaust the connection pool),
|
||||
// we batch all count queries into a single raw SQL query.
|
||||
// This reduces connection usage from 15 → 3 (batch counts + integrations + accounts).
|
||||
const [countsResult, integrations, ssoProviders] = await Promise.all([
|
||||
// Single query for all counts (13 metrics in one round-trip)
|
||||
prisma.$queryRaw<
|
||||
[
|
||||
{
|
||||
organizationCount: bigint;
|
||||
userCount: bigint;
|
||||
teamCount: bigint;
|
||||
projectCount: bigint;
|
||||
surveyCount: bigint;
|
||||
inProgressSurveyCount: bigint;
|
||||
completedSurveyCount: bigint;
|
||||
responseCountAllTime: bigint;
|
||||
responseCountSinceLastUpdate: bigint;
|
||||
displayCount: bigint;
|
||||
contactCount: bigint;
|
||||
segmentCount: bigint;
|
||||
newestResponseAt: Date | null;
|
||||
},
|
||||
]
|
||||
>`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM "Organization") as "organizationCount",
|
||||
(SELECT COUNT(*) FROM "User") as "userCount",
|
||||
(SELECT COUNT(*) FROM "Team") as "teamCount",
|
||||
(SELECT COUNT(*) FROM "Project") as "projectCount",
|
||||
(SELECT COUNT(*) FROM "Survey") as "surveyCount",
|
||||
(SELECT COUNT(*) FROM "Survey" WHERE status = 'inProgress') as "inProgressSurveyCount",
|
||||
(SELECT COUNT(*) FROM "Survey" WHERE status = 'completed') as "completedSurveyCount",
|
||||
(SELECT COUNT(*) FROM "Response") as "responseCountAllTime",
|
||||
(SELECT COUNT(*) FROM "Response" WHERE "created_at" > ${new Date(lastSent || 0)}) as "responseCountSinceLastUpdate",
|
||||
(SELECT COUNT(*) FROM "Display") as "displayCount",
|
||||
(SELECT COUNT(*) FROM "Contact") as "contactCount",
|
||||
(SELECT COUNT(*) FROM "Segment") as "segmentCount",
|
||||
(SELECT MAX("created_at") FROM "Response") as "newestResponseAt"
|
||||
`,
|
||||
// Keep these as separate queries since they need DISTINCT which is harder to optimize
|
||||
prisma.integration.findMany({ select: { type: true }, distinct: ["type"] }),
|
||||
prisma.account.findMany({ select: { provider: true }, distinct: ["provider"] }),
|
||||
]);
|
||||
|
||||
// Extract metrics from the batched query result and convert bigints to numbers
|
||||
const counts = countsResult[0];
|
||||
const organizationCount = Number(counts.organizationCount);
|
||||
const userCount = Number(counts.userCount);
|
||||
const teamCount = Number(counts.teamCount);
|
||||
const projectCount = Number(counts.projectCount);
|
||||
const surveyCount = Number(counts.surveyCount);
|
||||
const inProgressSurveyCount = Number(counts.inProgressSurveyCount);
|
||||
const completedSurveyCount = Number(counts.completedSurveyCount);
|
||||
const responseCountAllTime = Number(counts.responseCountAllTime);
|
||||
const responseCountSinceLastUpdate = Number(counts.responseCountSinceLastUpdate);
|
||||
const displayCount = Number(counts.displayCount);
|
||||
const contactCount = Number(counts.contactCount);
|
||||
const segmentCount = Number(counts.segmentCount);
|
||||
const newestResponse = counts.newestResponseAt ? { createdAt: counts.newestResponseAt } : null;
|
||||
|
||||
// Convert integration array to boolean map indicating which integrations are configured.
|
||||
const integrationMap = {
|
||||
notion: integrations.some((i) => i.type === IntegrationType.notion),
|
||||
googleSheets: integrations.some((i) => i.type === IntegrationType.googleSheets),
|
||||
airtable: integrations.some((i) => i.type === IntegrationType.airtable),
|
||||
slack: integrations.some((i) => i.type === IntegrationType.slack),
|
||||
};
|
||||
|
||||
// Check SSO configuration: either via environment variables or database records.
|
||||
// This detects which SSO providers are available/configured.
|
||||
const ssoMap = {
|
||||
github: !!env.GITHUB_ID || ssoProviders.some((p) => p.provider === "github"),
|
||||
google: !!env.GOOGLE_CLIENT_ID || ssoProviders.some((p) => p.provider === "google"),
|
||||
azureAd: !!env.AZUREAD_CLIENT_ID || ssoProviders.some((p) => p.provider === "azuread"),
|
||||
oidc: !!env.OIDC_CLIENT_ID || ssoProviders.some((p) => p.provider === "openid"),
|
||||
};
|
||||
|
||||
// Construct telemetry payload with usage statistics and configuration.
|
||||
const payload = {
|
||||
schemaVersion: 1, // Schema version for future compatibility
|
||||
// Core entity counts
|
||||
organizationCount,
|
||||
userCount,
|
||||
teamCount,
|
||||
projectCount,
|
||||
surveyCount,
|
||||
inProgressSurveyCount,
|
||||
completedSurveyCount,
|
||||
// Response metrics
|
||||
responseCountAllTime,
|
||||
responseCountSinceLastUsageUpdate: responseCountSinceLastUpdate, // Incremental since last telemetry
|
||||
displayCount,
|
||||
contactCount,
|
||||
segmentCount,
|
||||
integrations: integrationMap,
|
||||
infrastructure: {
|
||||
smtp: !!env.SMTP_HOST,
|
||||
s3: !!env.S3_BUCKET_NAME,
|
||||
prometheus: !!env.PROMETHEUS_ENABLED,
|
||||
},
|
||||
security: {
|
||||
recaptcha: !!(env.RECAPTCHA_SITE_KEY && env.RECAPTCHA_SECRET_KEY),
|
||||
},
|
||||
sso: ssoMap,
|
||||
meta: {
|
||||
version: packageJson.version, // Formbricks version for compatibility tracking
|
||||
},
|
||||
temporal: {
|
||||
instanceCreatedAt: oldestOrg.createdAt.toISOString(), // When instance was first created
|
||||
newestResponseAt: newestResponse?.createdAt.toISOString() || null, // Most recent activity
|
||||
},
|
||||
};
|
||||
|
||||
// Send telemetry to Formbricks Enterprise endpoint.
|
||||
// This endpoint collects usage statistics for enterprise license validation and analytics.
|
||||
const url = `https://ee.formbricks.com/api/v1/instances/${instanceId}/usage-updates`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry";
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
@@ -227,10 +226,6 @@ export const POST = async (request: Request) => {
|
||||
}
|
||||
});
|
||||
}
|
||||
if (event === "responseCreated") {
|
||||
// Send telemetry events
|
||||
await sendTelemetryEvents();
|
||||
}
|
||||
|
||||
return Response.json({ data: {} });
|
||||
};
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import {
|
||||
type TSurveyBlock,
|
||||
type TSurveyBlockLogic,
|
||||
type TSurveyBlockLogicAction,
|
||||
} from "@formbricks/types/surveys/blocks";
|
||||
import { type TSurveyBlock, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
|
||||
import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic";
|
||||
import {
|
||||
type TSurveyEnding,
|
||||
@@ -419,53 +415,6 @@ export const transformQuestionsToBlocks = (
|
||||
return blocks as TSurveyBlock[];
|
||||
};
|
||||
|
||||
const transformBlockLogicToQuestionLogic = (
|
||||
blockLogic: TSurveyBlockLogic[],
|
||||
blockIdToQuestionId: Map<string, string>,
|
||||
endingIds: Set<string>
|
||||
): unknown[] => {
|
||||
return blockLogic.map((item) => {
|
||||
const updatedConditions = convertElementToQuestionType(item.conditions);
|
||||
|
||||
if (!updatedConditions || !isConditionGroup(updatedConditions)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
conditions: updatedConditions,
|
||||
actions: reverseLogicActions(item.actions, blockIdToQuestionId, endingIds),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const applyBlockAttributesToElement = (
|
||||
element: Record<string, unknown>,
|
||||
block: TSurveyBlock,
|
||||
blockIdToQuestionId: Map<string, string>,
|
||||
endingIds: Set<string>
|
||||
): void => {
|
||||
if (element.type === "cta" && element.ctaButtonLabel) {
|
||||
element.buttonLabel = element.ctaButtonLabel;
|
||||
}
|
||||
|
||||
if (Array.isArray(block.logic) && block.logic.length > 0) {
|
||||
element.logic = transformBlockLogicToQuestionLogic(block.logic, blockIdToQuestionId, endingIds);
|
||||
}
|
||||
|
||||
if (block.logicFallback) {
|
||||
element.logicFallback = reverseLogicFallback(block.logicFallback, blockIdToQuestionId, endingIds);
|
||||
}
|
||||
|
||||
if (block.buttonLabel) {
|
||||
element.buttonLabel = block.buttonLabel;
|
||||
}
|
||||
|
||||
if (block.backButtonLabel) {
|
||||
element.backButtonLabel = block.backButtonLabel;
|
||||
}
|
||||
};
|
||||
|
||||
export const transformBlocksToQuestions = (
|
||||
blocks: TSurveyBlock[],
|
||||
endings: TSurveyEnding[] = []
|
||||
@@ -486,9 +435,41 @@ export const transformBlocksToQuestions = (
|
||||
for (const block of blocks) {
|
||||
if (block.elements.length === 0) continue;
|
||||
|
||||
const element = { ...block.elements[0] };
|
||||
const element = { ...block.elements[0] } as Record<string, unknown>;
|
||||
|
||||
applyBlockAttributesToElement(element, block, blockIdToQuestionId, endingIds);
|
||||
if (element.type === "cta" && element.ctaButtonLabel) {
|
||||
element.buttonLabel = element.ctaButtonLabel;
|
||||
}
|
||||
|
||||
if (Array.isArray(block.logic) && block.logic.length > 0) {
|
||||
element.logic = block.logic.map(
|
||||
(item: { id: string; conditions: TConditionGroup; actions: TSurveyBlockLogicAction[] }) => {
|
||||
const updatedConditions = convertElementToQuestionType(item.conditions);
|
||||
|
||||
if (!updatedConditions || !isConditionGroup(updatedConditions)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
conditions: updatedConditions,
|
||||
actions: reverseLogicActions(item.actions, blockIdToQuestionId, endingIds),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (block.logicFallback) {
|
||||
element.logicFallback = reverseLogicFallback(block.logicFallback, blockIdToQuestionId, endingIds);
|
||||
}
|
||||
|
||||
if (block.buttonLabel) {
|
||||
element.buttonLabel = block.buttonLabel;
|
||||
}
|
||||
|
||||
if (block.backButtonLabel) {
|
||||
element.backButtonLabel = block.backButtonLabel;
|
||||
}
|
||||
|
||||
questions.push(element);
|
||||
}
|
||||
|
||||
@@ -374,12 +374,10 @@ const processPictureSelectionFilter = (
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOptions = filterType.filterComboBoxValue
|
||||
.map((option) => {
|
||||
const index = parseInt(option.split(" ")[1]);
|
||||
return element?.choices[index - 1]?.id;
|
||||
})
|
||||
.filter(Boolean);
|
||||
const selectedOptions = filterType.filterComboBoxValue.map((option) => {
|
||||
const index = parseInt(option.split(" ")[1]);
|
||||
return element?.choices[index - 1].id;
|
||||
});
|
||||
|
||||
if (filterType.filterValue === "Includes all") {
|
||||
filters.data![elementId] = { op: "includesAll", value: selectedOptions };
|
||||
|
||||
@@ -1109,11 +1109,10 @@ const reviewPrompt = (t: TFunction): TTemplate => {
|
||||
required: false,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonExternal: true,
|
||||
ctaButtonLabel: t("templates.review_prompt_question_2_button_label"),
|
||||
}),
|
||||
],
|
||||
logic: [createBlockJumpLogic(reusableElementIds[1], localSurvey.endings[0].id, "isClicked")],
|
||||
buttonLabel: t("templates.next"),
|
||||
buttonLabel: t("templates.review_prompt_question_2_button_label"),
|
||||
backButtonLabel: t("templates.back"),
|
||||
t,
|
||||
}),
|
||||
@@ -1160,10 +1159,9 @@ const interviewPrompt = (t: TFunction): TTemplate => {
|
||||
buttonUrl: "https://cal.com/johannes",
|
||||
buttonExternal: true,
|
||||
required: false,
|
||||
ctaButtonLabel: t("templates.interview_prompt_question_1_button_label"),
|
||||
}),
|
||||
],
|
||||
buttonLabel: t("templates.next"),
|
||||
buttonLabel: t("templates.interview_prompt_question_1_button_label"),
|
||||
t,
|
||||
}),
|
||||
],
|
||||
@@ -2696,10 +2694,9 @@ const marketSiteClarity = (t: TFunction): TTemplate => {
|
||||
required: false,
|
||||
buttonUrl: "https://app.formbricks.com/auth/signup",
|
||||
buttonExternal: true,
|
||||
ctaButtonLabel: t("templates.market_site_clarity_question_3_button_label"),
|
||||
}),
|
||||
],
|
||||
buttonLabel: t("templates.next"),
|
||||
buttonLabel: t("templates.market_site_clarity_question_3_button_label"),
|
||||
t,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -1171,6 +1171,7 @@ checksums:
|
||||
environments/surveys/edit/automatically_mark_the_survey_as_complete_after: c6ede2a5515a4ca72b36aec2583f43aa
|
||||
environments/surveys/edit/back_button_label: 25af945e77336724b5276de291cc92d9
|
||||
environments/surveys/edit/background_styling: 4e1e6fd2ec767bbff8767f6c0f68a731
|
||||
environments/surveys/edit/block_deleted: c682259eb138ad84f8b4441abfd9b572
|
||||
environments/surveys/edit/block_duplicated: dc9e9fab2b1cd91f6c265324b34c6376
|
||||
environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6
|
||||
environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e
|
||||
|
||||
@@ -209,12 +209,6 @@ export const writeData = async (
|
||||
responses: string[],
|
||||
elements: string[]
|
||||
) => {
|
||||
if (responses.length !== elements.length) {
|
||||
throw new Error(
|
||||
`Array length mismatch: responses (${responses.length}) and elements (${elements.length}) must be equal`
|
||||
);
|
||||
}
|
||||
|
||||
// 1) Build the record payload
|
||||
const data: Record<string, string> = {};
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
|
||||
@@ -30,7 +30,7 @@ export const createI18nString = (
|
||||
return i18nString;
|
||||
} else {
|
||||
// It's a regular string, so create a new i18n object
|
||||
const i18nString = {
|
||||
const i18nString: any = {
|
||||
[targetLanguageCode ?? "default"]: text,
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ export const createI18nString = (
|
||||
};
|
||||
|
||||
// Type guard to check if an object is an I18nString
|
||||
export const isI18nObject = (obj: unknown): obj is TI18nString => {
|
||||
export const isI18nObject = (obj: any): obj is TI18nString => {
|
||||
return typeof obj === "object" && obj !== null && Object.keys(obj).includes("default");
|
||||
};
|
||||
|
||||
@@ -92,7 +92,7 @@ export const iso639Identifiers = iso639Languages.map((language) => language.alph
|
||||
|
||||
// Helper function to add language keys to a multi-language object (e.g. survey or question)
|
||||
// Iterates over the object recursively and adds empty strings for new language keys
|
||||
export const addMultiLanguageLabels = (object: unknown, languageSymbols: string[]): any => {
|
||||
export const addMultiLanguageLabels = (object: any, languageSymbols: string[]): any => {
|
||||
// Helper function to add language keys to a multi-language object
|
||||
function addLanguageKeys(obj: { default: string; [key: string]: string }) {
|
||||
languageSymbols.forEach((lang) => {
|
||||
@@ -103,14 +103,14 @@ export const addMultiLanguageLabels = (object: unknown, languageSymbols: string[
|
||||
}
|
||||
|
||||
// Recursive function to process an object or array
|
||||
function processObject(obj: unknown) {
|
||||
function processObject(obj: any) {
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach((item) => processObject(item));
|
||||
} else if (obj && typeof obj === "object") {
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
if (key === "default" && typeof obj[key] === "string") {
|
||||
addLanguageKeys(obj as { default: string; [key: string]: string });
|
||||
addLanguageKeys(obj);
|
||||
} else {
|
||||
processObject(obj[key]);
|
||||
}
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { updateResponse } from "./service";
|
||||
import { calculateTtcTotal } from "./utils";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
response: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./utils")>();
|
||||
return {
|
||||
...actual,
|
||||
calculateTtcTotal: vi.fn((ttc) => ({
|
||||
...ttc,
|
||||
_total: Object.values(ttc as Record<string, number>).reduce((a, b) => a + b, 0),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const mockResponseId = "response-123";
|
||||
|
||||
const createMockCurrentResponse = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: mockResponseId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId: "survey-123",
|
||||
finished: false,
|
||||
endingId: null,
|
||||
data: {},
|
||||
meta: {},
|
||||
ttc: {},
|
||||
variables: {},
|
||||
contactAttributes: {},
|
||||
singleUseId: null,
|
||||
language: "en",
|
||||
displayId: "display-123",
|
||||
contact: null,
|
||||
tags: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockResponseInput = (overrides: Partial<TResponseUpdateInput> = {}): TResponseUpdateInput => ({
|
||||
finished: false,
|
||||
data: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("updateResponse", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("TTC merging behavior", () => {
|
||||
test("should merge new TTC with existing TTC from previous blocks", async () => {
|
||||
const currentResponse = createMockCurrentResponse({
|
||||
ttc: { element1: 1000, element2: 2000 },
|
||||
});
|
||||
|
||||
const responseInput = createMockResponseInput({
|
||||
ttc: { element3: 3000 },
|
||||
finished: false,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockResolvedValue({
|
||||
...currentResponse,
|
||||
ttc: { element1: 1000, element2: 2000, element3: 3000 },
|
||||
} as any);
|
||||
|
||||
await updateResponse(mockResponseId, responseInput);
|
||||
|
||||
expect(prisma.response.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
ttc: { element1: 1000, element2: 2000, element3: 3000 },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should preserve existing TTC when no new TTC is provided", async () => {
|
||||
const currentResponse = createMockCurrentResponse({
|
||||
ttc: { element1: 1000, element2: 2000 },
|
||||
});
|
||||
|
||||
const responseInput = createMockResponseInput({
|
||||
finished: false,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockResolvedValue(currentResponse as any);
|
||||
|
||||
await updateResponse(mockResponseId, responseInput);
|
||||
|
||||
expect(prisma.response.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
ttc: { element1: 1000, element2: 2000 },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should calculate total TTC when response is finished", async () => {
|
||||
const currentResponse = createMockCurrentResponse({
|
||||
ttc: { element1: 1000, element2: 2000 },
|
||||
});
|
||||
|
||||
const responseInput = createMockResponseInput({
|
||||
ttc: { element3: 3000 },
|
||||
finished: true,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockResolvedValue({
|
||||
...currentResponse,
|
||||
finished: true,
|
||||
ttc: { element1: 1000, element2: 2000, element3: 3000, _total: 6000 },
|
||||
} as any);
|
||||
|
||||
await updateResponse(mockResponseId, responseInput);
|
||||
|
||||
expect(calculateTtcTotal).toHaveBeenCalledWith({
|
||||
element1: 1000,
|
||||
element2: 2000,
|
||||
element3: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
test("should not calculate total TTC when response is not finished", async () => {
|
||||
const currentResponse = createMockCurrentResponse({
|
||||
ttc: { element1: 1000 },
|
||||
});
|
||||
|
||||
const responseInput = createMockResponseInput({
|
||||
ttc: { element2: 2000 },
|
||||
finished: false,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockResolvedValue({
|
||||
...currentResponse,
|
||||
ttc: { element1: 1000, element2: 2000 },
|
||||
} as any);
|
||||
|
||||
await updateResponse(mockResponseId, responseInput);
|
||||
|
||||
expect(calculateTtcTotal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle empty existing TTC", async () => {
|
||||
const currentResponse = createMockCurrentResponse({
|
||||
ttc: {},
|
||||
});
|
||||
|
||||
const responseInput = createMockResponseInput({
|
||||
ttc: { element1: 1000 },
|
||||
finished: false,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockResolvedValue({
|
||||
...currentResponse,
|
||||
ttc: { element1: 1000 },
|
||||
} as any);
|
||||
|
||||
await updateResponse(mockResponseId, responseInput);
|
||||
|
||||
expect(prisma.response.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
ttc: { element1: 1000 },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle null existing TTC", async () => {
|
||||
const currentResponse = createMockCurrentResponse({
|
||||
ttc: null,
|
||||
});
|
||||
|
||||
const responseInput = createMockResponseInput({
|
||||
ttc: { element1: 1000 },
|
||||
finished: false,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockResolvedValue({
|
||||
...currentResponse,
|
||||
ttc: { element1: 1000 },
|
||||
} as any);
|
||||
|
||||
await updateResponse(mockResponseId, responseInput);
|
||||
|
||||
expect(prisma.response.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
ttc: { element1: 1000 },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should overwrite existing element TTC with new value for same element", async () => {
|
||||
const currentResponse = createMockCurrentResponse({
|
||||
ttc: { element1: 1000 },
|
||||
});
|
||||
|
||||
const responseInput = createMockResponseInput({
|
||||
ttc: { element1: 1500 },
|
||||
finished: false,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockResolvedValue({
|
||||
...currentResponse,
|
||||
ttc: { element1: 1500 },
|
||||
} as any);
|
||||
|
||||
await updateResponse(mockResponseId, responseInput);
|
||||
|
||||
expect(prisma.response.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
ttc: { element1: 1500 },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("data merging behavior", () => {
|
||||
test("should merge new data with existing data", async () => {
|
||||
const currentResponse = createMockCurrentResponse({
|
||||
data: { question1: "answer1" },
|
||||
});
|
||||
|
||||
const responseInput = createMockResponseInput({
|
||||
data: { question2: "answer2" },
|
||||
finished: false,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockResolvedValue({
|
||||
...currentResponse,
|
||||
data: { question1: "answer1", question2: "answer2" },
|
||||
} as any);
|
||||
|
||||
await updateResponse(mockResponseId, responseInput);
|
||||
|
||||
expect(prisma.response.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
data: { question1: "answer1", question2: "answer2" },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("variables merging behavior", () => {
|
||||
test("should merge new variables with existing variables", async () => {
|
||||
const currentResponse = createMockCurrentResponse({
|
||||
variables: { var1: "value1" },
|
||||
});
|
||||
|
||||
const responseInput = createMockResponseInput({
|
||||
variables: { var2: "value2" },
|
||||
finished: false,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockResolvedValue({
|
||||
...currentResponse,
|
||||
variables: { var1: "value1", var2: "value2" },
|
||||
} as any);
|
||||
|
||||
await updateResponse(mockResponseId, responseInput);
|
||||
|
||||
expect(prisma.response.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
variables: { var1: "value1", var2: "value2" },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
test("should throw ResourceNotFoundError when response does not exist", async () => {
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(null);
|
||||
|
||||
const responseInput = createMockResponseInput();
|
||||
|
||||
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma errors", async () => {
|
||||
const currentResponse = createMockCurrentResponse();
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
})
|
||||
);
|
||||
|
||||
const responseInput = createMockResponseInput();
|
||||
|
||||
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -507,16 +507,11 @@ export const updateResponse = async (
|
||||
...currentResponse.data,
|
||||
...responseInput.data,
|
||||
};
|
||||
// merge ttc object (similar to data) to preserve TTC from previous blocks
|
||||
const currentTtc = currentResponse.ttc;
|
||||
const mergedTtc = responseInput.ttc
|
||||
? {
|
||||
...currentTtc,
|
||||
...responseInput.ttc,
|
||||
}
|
||||
: currentTtc;
|
||||
// Calculate total only when finished
|
||||
const ttc = responseInput.finished ? calculateTtcTotal(mergedTtc) : mergedTtc;
|
||||
const ttc = responseInput.ttc
|
||||
? responseInput.finished
|
||||
? calculateTtcTotal(responseInput.ttc)
|
||||
: responseInput.ttc
|
||||
: {};
|
||||
const language = responseInput.language;
|
||||
const variables = {
|
||||
...currentResponse.variables,
|
||||
|
||||
@@ -174,7 +174,7 @@ describe("Response Processing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getElementResponseMapping", () => {
|
||||
describe("getQuestionResponseMapping", () => {
|
||||
const mockSurvey = {
|
||||
id: "survey1",
|
||||
type: "link" as const,
|
||||
|
||||
@@ -351,6 +351,7 @@ describe("checkForInvalidMediaInBlocks", () => {
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
console.log(result.error);
|
||||
expect(result.error.message).toBe(
|
||||
'Invalid image URL in choice 1 of question 1 of block "Welcome Block"'
|
||||
);
|
||||
|
||||
@@ -1256,6 +1256,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach",
|
||||
"back_button_label": "Zurück\"- Button ",
|
||||
"background_styling": "Hintergründe",
|
||||
"block_deleted": "Block gelöscht.",
|
||||
"block_duplicated": "Block dupliziert.",
|
||||
"bold": "Fett",
|
||||
"brand_color": "Markenfarbe",
|
||||
|
||||
@@ -1256,6 +1256,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after",
|
||||
"back_button_label": "\"Back\" Button Label",
|
||||
"background_styling": "Background Styling",
|
||||
"block_deleted": "Block deleted.",
|
||||
"block_duplicated": "Block duplicated.",
|
||||
"bold": "Bold",
|
||||
"brand_color": "Brand color",
|
||||
|
||||
@@ -1256,6 +1256,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automáticamente la encuesta como completa después de",
|
||||
"back_button_label": "Etiqueta del botón \"Atrás\"",
|
||||
"background_styling": "Estilo de fondo",
|
||||
"block_deleted": "Bloque eliminado.",
|
||||
"block_duplicated": "Bloque duplicado.",
|
||||
"bold": "Negrita",
|
||||
"brand_color": "Color de marca",
|
||||
|
||||
@@ -1256,6 +1256,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marquer automatiquement l'enquête comme terminée après",
|
||||
"back_button_label": "Label du bouton \"Retour''",
|
||||
"background_styling": "Style de fond",
|
||||
"block_deleted": "Bloc supprimé.",
|
||||
"block_duplicated": "Bloc dupliqué.",
|
||||
"bold": "Gras",
|
||||
"brand_color": "Couleur de marque",
|
||||
|
||||
@@ -1256,6 +1256,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "フォームを自動的に完了としてマークする",
|
||||
"back_button_label": "「戻る」ボタンのラベル",
|
||||
"background_styling": "背景のスタイル",
|
||||
"block_deleted": "ブロックが削除されました。",
|
||||
"block_duplicated": "ブロックが複製されました。",
|
||||
"bold": "太字",
|
||||
"brand_color": "ブランドカラー",
|
||||
|
||||
@@ -1256,6 +1256,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Markeer de enquête daarna automatisch als voltooid",
|
||||
"back_button_label": "Knoplabel 'Terug'",
|
||||
"background_styling": "Achtergrondstyling",
|
||||
"block_deleted": "Blok verwijderd.",
|
||||
"block_duplicated": "Blok gedupliceerd.",
|
||||
"bold": "Vetgedrukt",
|
||||
"brand_color": "Merk kleur",
|
||||
|
||||
@@ -1256,6 +1256,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após",
|
||||
"back_button_label": "Voltar",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"block_deleted": "Bloco excluído.",
|
||||
"block_duplicated": "Bloco duplicado.",
|
||||
"bold": "Negrito",
|
||||
"brand_color": "Cor da marca",
|
||||
|
||||
@@ -1256,6 +1256,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente o inquérito como concluído após",
|
||||
"back_button_label": "Rótulo do botão \"Voltar\"",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"block_deleted": "Bloco eliminado.",
|
||||
"block_duplicated": "Bloco duplicado.",
|
||||
"bold": "Negrito",
|
||||
"brand_color": "Cor da marca",
|
||||
|
||||
@@ -1256,6 +1256,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcați automat sondajul ca finalizat după",
|
||||
"back_button_label": "Etichetă buton \"Înapoi\"",
|
||||
"background_styling": "Stilizare fundal",
|
||||
"block_deleted": "Bloc șters.",
|
||||
"block_duplicated": "Bloc duplicat.",
|
||||
"bold": "Îngroșat",
|
||||
"brand_color": "Culoarea brandului",
|
||||
|
||||
@@ -1256,6 +1256,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "自动 标记 调查 为 完成 在",
|
||||
"back_button_label": "\"返回\" 按钮标签",
|
||||
"background_styling": "背景 样式",
|
||||
"block_deleted": "区块已删除。",
|
||||
"block_duplicated": "区块已复制。",
|
||||
"bold": "粗体",
|
||||
"brand_color": "品牌 颜色",
|
||||
|
||||
@@ -1256,6 +1256,7 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "在指定時間後自動將問卷標記為完成",
|
||||
"back_button_label": "「返回」按鈕標籤",
|
||||
"background_styling": "背景樣式設定",
|
||||
"block_deleted": "區塊已刪除。",
|
||||
"block_duplicated": "區塊已複製。",
|
||||
"bold": "粗體",
|
||||
"brand_color": "品牌顏色",
|
||||
|
||||
+6
-4
@@ -15,16 +15,18 @@ interface LanguageDropdownProps {
|
||||
|
||||
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => {
|
||||
const [showLanguageSelect, setShowLanguageSelect] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
const languageDropdownRef = useRef(null);
|
||||
const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
|
||||
|
||||
useClickOutside(containerRef, () => setShowLanguageSelect(false));
|
||||
useClickOutside(languageDropdownRef, () => setShowLanguageSelect(false));
|
||||
|
||||
return (
|
||||
enabledLanguages.length > 1 && (
|
||||
<div className="relative" ref={containerRef}>
|
||||
<div className="relative">
|
||||
{showLanguageSelect && (
|
||||
<div className="absolute top-12 z-30 max-h-64 max-w-48 overflow-auto rounded-lg border bg-slate-900 p-1 text-sm text-white">
|
||||
<div
|
||||
className="absolute top-12 z-30 w-fit rounded-lg border bg-slate-900 p-1 text-sm text-white"
|
||||
ref={languageDropdownRef}>
|
||||
{enabledLanguages.map((surveyLanguage) => (
|
||||
<button
|
||||
key={surveyLanguage.language.code}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function DefaultLanguageSelect({
|
||||
});
|
||||
}}
|
||||
value={`${defaultLanguage?.code}`}>
|
||||
<SelectTrigger className="w-full max-w-full truncate px-4 text-xs text-slate-800">
|
||||
<SelectTrigger className="xs:w-[180px] xs:text-sm w-full px-4 text-xs text-slate-800 dark:border-slate-400 dark:bg-slate-700 dark:text-slate-300">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -57,7 +57,7 @@ export function LanguageIndicator({
|
||||
</button>
|
||||
{showLanguageDropdown ? (
|
||||
<div
|
||||
className="absolute right-0 z-30 mt-1 max-h-64 space-y-2 overflow-auto rounded-md bg-slate-900 p-1 text-xs text-white"
|
||||
className="absolute right-0 z-30 mt-1 space-y-2 rounded-md bg-slate-900 p-1 text-xs text-white"
|
||||
ref={languageDropdownRef}>
|
||||
{surveyLanguages.map(
|
||||
(language) =>
|
||||
|
||||
@@ -66,10 +66,10 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
|
||||
disabled={disabled}
|
||||
onClick={toggleDropdown}
|
||||
variant="ghost">
|
||||
<span className="mr-2 min-w-0 truncate">
|
||||
<span className="mr-2">
|
||||
{selectedOption ? getLabelForLocale(selectedOption) : t("common.select")}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0" />
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<div
|
||||
className={`absolute right-0 z-30 mt-2 space-y-1 rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 ${isOpen ? "" : "hidden"}`}>
|
||||
@@ -84,10 +84,10 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
|
||||
value={searchTerm}
|
||||
/>
|
||||
<div className="max-h-96 overflow-auto">
|
||||
{filteredItems.map((item) => (
|
||||
{filteredItems.map((item, index) => (
|
||||
<button
|
||||
className="block w-full cursor-pointer rounded-md px-4 py-2 text-left text-slate-700 hover:bg-slate-100 active:bg-blue-100"
|
||||
key={item.alpha2}
|
||||
key={index}
|
||||
onClick={() => {
|
||||
handleOptionSelect(item);
|
||||
}}>
|
||||
|
||||
@@ -19,7 +19,7 @@ export function LanguageToggle({ language, isChecked, onToggle, onEdit, locale }
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex max-w-96 items-center space-x-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
id={`${language.code}-toggle`}
|
||||
@@ -28,18 +28,15 @@ export function LanguageToggle({ language, isChecked, onToggle, onEdit, locale }
|
||||
onToggle();
|
||||
}}
|
||||
/>
|
||||
<Label className="truncate font-medium text-slate-800" htmlFor={`${language.code}-toggle`}>
|
||||
<Label className="font-medium text-slate-800" htmlFor={`${language.code}-toggle`}>
|
||||
{getLanguageLabel(language.code, locale)}
|
||||
</Label>
|
||||
{isChecked ? (
|
||||
<button
|
||||
className="truncate text-xs text-slate-600 underline hover:text-slate-800"
|
||||
onClick={onEdit}
|
||||
type="button">
|
||||
<p className="cursor-pointer text-xs text-slate-600 underline" onClick={onEdit}>
|
||||
{t("environments.surveys.edit.edit_translations", {
|
||||
lang: getLanguageLabel(language.code, locale),
|
||||
})}
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/
|
||||
import { cn } from "@/lib/cn";
|
||||
import { IS_STORAGE_CONFIGURED, SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { BrandingSettingsCard } from "@/modules/ee/whitelabel/remove-branding/components/branding-settings-card";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||
@@ -26,7 +26,7 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const canRemoveBranding = await getRemoveBrandingPermission(organization.billing.plan);
|
||||
const canRemoveBranding = await getWhiteLabelPermission(organization.billing.plan);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
|
||||
+2
-7
@@ -141,21 +141,16 @@ export const RecallItemSelect = ({
|
||||
|
||||
const getRecallItemIcon = (recallItem: TSurveyRecallItem) => {
|
||||
switch (recallItem.type) {
|
||||
case "element": {
|
||||
case "element":
|
||||
const element = elements.find((element) => element.id === recallItem.id);
|
||||
if (element) {
|
||||
return elementIconMapping[element?.type as keyof typeof elementIconMapping];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case "hiddenField":
|
||||
return EyeOffIcon;
|
||||
case "variable": {
|
||||
case "variable":
|
||||
const variable = localSurvey.variables.find((variable) => variable.id === recallItem.id);
|
||||
return variable?.type === "number" ? FileDigitIcon : FileTextIcon;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export const ElementFormInput = ({
|
||||
: isEndingCard
|
||||
? localSurvey.endings[elementIdx - elements.length].id
|
||||
: currentElement.id;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
//eslint-disable-next-line
|
||||
}, [isWelcomeCard, isEndingCard, currentElement?.id]);
|
||||
const endingCard = localSurvey.endings.find((ending) => ending.id === elementId);
|
||||
|
||||
|
||||
@@ -133,16 +133,13 @@ export const BlockCard = ({
|
||||
// A button label is invalid if it exists but doesn't have valid text for all enabled languages
|
||||
const surveyLanguages = localSurvey.languages ?? [];
|
||||
const hasInvalidButtonLabel =
|
||||
block.buttonLabel !== undefined &&
|
||||
block.buttonLabel["default"]?.trim() !== "" &&
|
||||
!isLabelValidForAllLanguages(block.buttonLabel, surveyLanguages);
|
||||
block.buttonLabel !== undefined && !isLabelValidForAllLanguages(block.buttonLabel, surveyLanguages);
|
||||
|
||||
// Check if back button label is invalid
|
||||
// Back button label should exist for all blocks except the first one
|
||||
const hasInvalidBackButtonLabel =
|
||||
blockIdx > 0 &&
|
||||
block.backButtonLabel !== undefined &&
|
||||
block.backButtonLabel["default"]?.trim() !== "" &&
|
||||
!isLabelValidForAllLanguages(block.backButtonLabel, surveyLanguages);
|
||||
|
||||
// Block should be highlighted if it has invalid elements OR invalid button labels
|
||||
@@ -407,7 +404,7 @@ export const BlockCard = ({
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${isOpen && "pb-4"}`}>
|
||||
{shouldShowCautionAlert(element.type) && (
|
||||
<Alert variant="warning" size="small" className="w-fill mt-2" role="alert">
|
||||
<Alert variant="warning" size="small" className="w-fill" role="alert">
|
||||
<AlertTitle>{t("environments.surveys.edit.caution_text")}</AlertTitle>
|
||||
<AlertButton onClick={() => onAlertTrigger()}>
|
||||
{t("common.learn_more")}
|
||||
|
||||
@@ -18,12 +18,13 @@ import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TSurveyBlock, TSurveyBlockLogic, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
|
||||
import { findBlocksWithCyclicLogic } from "@formbricks/types/surveys/blocks-validation";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
|
||||
import { addMultiLanguageLabels, createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { isConditionGroup } from "@/lib/surveyLogic/utils";
|
||||
import { checkForEmptyFallBackValue } from "@/lib/utils/recall";
|
||||
@@ -36,6 +37,7 @@ import { EditWelcomeCard } from "@/modules/survey/editor/components/edit-welcome
|
||||
import { HiddenFieldsCard } from "@/modules/survey/editor/components/hidden-fields-card";
|
||||
import { SurveyVariablesCard } from "@/modules/survey/editor/components/survey-variables-card";
|
||||
import {
|
||||
addBlock,
|
||||
addElementToBlock,
|
||||
deleteBlock,
|
||||
deleteElementFromBlock,
|
||||
@@ -43,12 +45,16 @@ import {
|
||||
findElementLocation,
|
||||
moveBlock as moveBlockHelper,
|
||||
moveElementInBlock,
|
||||
renumberBlocks,
|
||||
updateElementInBlock,
|
||||
} from "@/modules/survey/editor/lib/blocks";
|
||||
import { findElementUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { isEndingCardValid, isWelcomeCardValid, validateSurveyElementsInBatch } from "../lib/validation";
|
||||
import {
|
||||
isEndingCardValid,
|
||||
isWelcomeCardValid,
|
||||
validateElement,
|
||||
validateSurveyElementsInBatch,
|
||||
} from "../lib/validation";
|
||||
|
||||
interface ElementsViewProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -106,6 +112,8 @@ export const ElementsView = ({
|
||||
|
||||
const surveyLanguages = localSurvey.languages;
|
||||
|
||||
const getElementIdFromBlockId = (block: TSurveyBlock): string => block.elements[0].id;
|
||||
|
||||
const getBlockName = (index: number): string => {
|
||||
return `Block ${index + 1}`;
|
||||
};
|
||||
@@ -224,116 +232,121 @@ export const ElementsView = ({
|
||||
}
|
||||
}, [localSurvey.welcomeCard, localSurvey.endings, surveyLanguages, invalidElements, setInvalidElements]);
|
||||
|
||||
// function to validate individual elements
|
||||
const validateSurveyElement = (element: TSurveyElement) => {
|
||||
// prevent this function to execute further if user hasnt still tried to save the survey
|
||||
if (invalidElements === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (validateElement(element, surveyLanguages)) {
|
||||
const blocksWithCyclicLogic = findBlocksWithCyclicLogic(localSurvey.blocks);
|
||||
|
||||
for (const blockId of blocksWithCyclicLogic) {
|
||||
const block = localSurvey.blocks.find((b) => b.id === blockId);
|
||||
if (block) {
|
||||
const elementId = getElementIdFromBlockId(block);
|
||||
if (elementId === element.id) {
|
||||
setInvalidElements([...invalidElements, element.id]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInvalidElements(invalidElements.filter((id) => id !== element.id));
|
||||
return;
|
||||
}
|
||||
|
||||
setInvalidElements([...invalidElements, element.id]);
|
||||
return;
|
||||
};
|
||||
|
||||
const updateElement = (elementIdx: number, updatedAttributes: any) => {
|
||||
// Get element ID from current elements array (for validation)
|
||||
const element = elements[elementIdx];
|
||||
if (!element) return;
|
||||
|
||||
// Store element ID for use in functional updater
|
||||
const elementId = element.id;
|
||||
const { blockId, blockIndex } = findElementLocation(localSurvey, element.id);
|
||||
if (!blockId || blockIndex === -1) return;
|
||||
|
||||
// Track side effects that need to happen after state update
|
||||
let newActiveElementId: string | null = null;
|
||||
let invalidElementsUpdate: string[] | null = null;
|
||||
let updatedSurvey = { ...localSurvey };
|
||||
|
||||
// Use functional update to ensure we work with the latest state
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
// Re-find element location in the CURRENT state to avoid stale data
|
||||
const { blockId, blockIndex } = findElementLocation(prevSurvey, elementId);
|
||||
// Handle block-level attributes (logic, logicFallback, buttonLabel, backButtonLabel) separately
|
||||
const blockLevelAttributes: any = {};
|
||||
const elementLevelAttributes: any = {};
|
||||
|
||||
// If element no longer exists in survey (e.g., block was deleted), don't update
|
||||
if (!blockId || blockIndex === -1) {
|
||||
return prevSurvey;
|
||||
Object.keys(updatedAttributes).forEach((key) => {
|
||||
if (key === "logic" || key === "logicFallback" || key === "buttonLabel" || key === "backButtonLabel") {
|
||||
blockLevelAttributes[key] = updatedAttributes[key];
|
||||
} else {
|
||||
elementLevelAttributes[key] = updatedAttributes[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Update block-level attributes if any
|
||||
if (Object.keys(blockLevelAttributes).length > 0) {
|
||||
const blocks = [...(updatedSurvey.blocks ?? [])];
|
||||
blocks[blockIndex] = {
|
||||
...blocks[blockIndex],
|
||||
...blockLevelAttributes,
|
||||
};
|
||||
updatedSurvey = { ...updatedSurvey, blocks };
|
||||
}
|
||||
|
||||
// Handle element ID changes
|
||||
if ("id" in elementLevelAttributes) {
|
||||
// if the survey element whose id is to be changed is linked to logic of any other survey then changing it
|
||||
const initialElementId = element.id;
|
||||
updatedSurvey = handleElementLogicChange(updatedSurvey, initialElementId, elementLevelAttributes.id);
|
||||
if (invalidElements?.includes(initialElementId)) {
|
||||
setInvalidElements(
|
||||
invalidElements.map((id) => (id === initialElementId ? elementLevelAttributes.id : id))
|
||||
);
|
||||
}
|
||||
|
||||
let updatedSurvey = { ...prevSurvey };
|
||||
// relink the element to internal Id
|
||||
internalElementIdMap[elementLevelAttributes.id] = internalElementIdMap[element.id];
|
||||
delete internalElementIdMap[element.id];
|
||||
setActiveElementId(elementLevelAttributes.id);
|
||||
}
|
||||
|
||||
// Handle block-level attributes (logic, logicFallback, buttonLabel, backButtonLabel) separately
|
||||
const blockLevelAttributes: any = {};
|
||||
const elementLevelAttributes: any = {};
|
||||
// Update element-level attributes if any
|
||||
if (Object.keys(elementLevelAttributes).length > 0) {
|
||||
const attributesToCheck = ["upperLabel", "lowerLabel"];
|
||||
|
||||
Object.keys(updatedAttributes).forEach((key) => {
|
||||
if (
|
||||
key === "logic" ||
|
||||
key === "logicFallback" ||
|
||||
key === "buttonLabel" ||
|
||||
key === "backButtonLabel"
|
||||
) {
|
||||
blockLevelAttributes[key] = updatedAttributes[key];
|
||||
} else {
|
||||
elementLevelAttributes[key] = updatedAttributes[key];
|
||||
// If the value of upperLabel or lowerLabel is equal to {default:""}, then delete the key
|
||||
const cleanedAttributes = { ...elementLevelAttributes };
|
||||
attributesToCheck.forEach((attribute) => {
|
||||
if (Object.keys(cleanedAttributes).includes(attribute)) {
|
||||
const currentLabel = cleanedAttributes[attribute];
|
||||
if (
|
||||
currentLabel &&
|
||||
Object.keys(currentLabel).length === 1 &&
|
||||
currentLabel["default"].trim() === ""
|
||||
) {
|
||||
delete cleanedAttributes[attribute];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update block-level attributes if any
|
||||
if (Object.keys(blockLevelAttributes).length > 0) {
|
||||
const blocks = [...(updatedSurvey.blocks ?? [])];
|
||||
blocks[blockIndex] = {
|
||||
...blocks[blockIndex],
|
||||
...blockLevelAttributes,
|
||||
};
|
||||
updatedSurvey = { ...updatedSurvey, blocks };
|
||||
const result = updateElementInBlock(updatedSurvey, blockId, element.id, cleanedAttributes);
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle element ID changes
|
||||
if ("id" in elementLevelAttributes) {
|
||||
const initialElementId = elementId;
|
||||
updatedSurvey = handleElementLogicChange(updatedSurvey, initialElementId, elementLevelAttributes.id);
|
||||
updatedSurvey = result.data;
|
||||
|
||||
// Track side effects to apply after state update
|
||||
if (invalidElements?.includes(initialElementId)) {
|
||||
invalidElementsUpdate = invalidElements.map((id) =>
|
||||
id === initialElementId ? elementLevelAttributes.id : id
|
||||
);
|
||||
}
|
||||
|
||||
// Track new active element ID
|
||||
newActiveElementId = elementLevelAttributes.id;
|
||||
|
||||
// Update internal element ID map
|
||||
internalElementIdMap[elementLevelAttributes.id] = internalElementIdMap[elementId];
|
||||
delete internalElementIdMap[elementId];
|
||||
// Validate the updated element
|
||||
const updatedElement = updatedSurvey.blocks
|
||||
?.flatMap((b) => b.elements)
|
||||
.find((q) => q.id === (cleanedAttributes.id ?? element.id));
|
||||
if (updatedElement) {
|
||||
validateSurveyElement(updatedElement);
|
||||
}
|
||||
|
||||
// Update element-level attributes if any
|
||||
if (Object.keys(elementLevelAttributes).length > 0) {
|
||||
const attributesToCheck = ["upperLabel", "lowerLabel"];
|
||||
|
||||
// If the value of upperLabel or lowerLabel is equal to {default:""}, then delete the key
|
||||
const cleanedAttributes = { ...elementLevelAttributes };
|
||||
attributesToCheck.forEach((attribute) => {
|
||||
if (Object.keys(cleanedAttributes).includes(attribute)) {
|
||||
const currentLabel = cleanedAttributes[attribute];
|
||||
if (
|
||||
currentLabel &&
|
||||
Object.keys(currentLabel).length === 1 &&
|
||||
currentLabel["default"].trim() === ""
|
||||
) {
|
||||
delete cleanedAttributes[attribute];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const result = updateElementInBlock(updatedSurvey, blockId, elementId, cleanedAttributes);
|
||||
|
||||
if (!result.ok) {
|
||||
// Can't show toast inside functional updater, return unchanged
|
||||
return prevSurvey;
|
||||
}
|
||||
|
||||
updatedSurvey = result.data;
|
||||
}
|
||||
|
||||
return updatedSurvey;
|
||||
});
|
||||
|
||||
// Apply side effects after state update is queued
|
||||
if (invalidElementsUpdate) {
|
||||
setInvalidElements(invalidElementsUpdate);
|
||||
}
|
||||
if (newActiveElementId) {
|
||||
setActiveElementId(newActiveElementId);
|
||||
}
|
||||
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
// Update block logic (block-level property)
|
||||
@@ -502,27 +515,23 @@ export const ElementsView = ({
|
||||
};
|
||||
|
||||
const addElement = (element: TSurveyElement, index?: number) => {
|
||||
// Use functional update to ensure we work with the latest state
|
||||
const newBlockId = createId();
|
||||
const languageSymbols = extractLanguageCodes(localSurvey.languages);
|
||||
const updatedElement = addMultiLanguageLabels(element, languageSymbols);
|
||||
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const languageSymbols = extractLanguageCodes(prevSurvey.languages);
|
||||
const updatedElement = addMultiLanguageLabels(element, languageSymbols);
|
||||
const blockName = getBlockName(index ?? localSurvey.blocks.length);
|
||||
const newBlock = {
|
||||
name: blockName,
|
||||
elements: [{ ...updatedElement, isDraft: true }],
|
||||
};
|
||||
|
||||
const newBlock = {
|
||||
id: newBlockId,
|
||||
name: getBlockName(index ?? prevSurvey.blocks.length),
|
||||
elements: [{ ...updatedElement, isDraft: true }],
|
||||
buttonLabel: createI18nString(t(""), []),
|
||||
backButtonLabel: createI18nString(t(""), []),
|
||||
};
|
||||
const result = addBlock(t, localSurvey, newBlock, index);
|
||||
|
||||
return {
|
||||
...prevSurvey,
|
||||
blocks: [...prevSurvey.blocks, newBlock],
|
||||
};
|
||||
});
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalSurvey(result.data);
|
||||
setActiveElementId(element.id);
|
||||
internalElementIdMap[element.id] = createId();
|
||||
};
|
||||
@@ -673,40 +682,23 @@ export const ElementsView = ({
|
||||
};
|
||||
|
||||
const deleteBlockById = (blockId: string) => {
|
||||
// First check if block exists in current state (for validation and calculating next active element)
|
||||
const blockExists = localSurvey.blocks.some((b) => b.id === blockId);
|
||||
if (!blockExists) {
|
||||
const result = deleteBlock(localSurvey, blockId);
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent deleting the last block
|
||||
if (localSurvey.blocks.length === 1) {
|
||||
return;
|
||||
// Set active element to the first element of the first remaining block or ending card
|
||||
const newBlocks = result.data.blocks ?? [];
|
||||
if (newBlocks.length > 0 && newBlocks[0].elements.length > 0) {
|
||||
setActiveElementId(newBlocks[0].elements[0].id);
|
||||
} else if (result.data.endings[0]) {
|
||||
setActiveElementId(result.data.endings[0].id);
|
||||
}
|
||||
|
||||
// Calculate the new active element before deletion
|
||||
const remainingBlocks = localSurvey.blocks.filter((b) => b.id !== blockId);
|
||||
let newActiveElementId: string | null = null;
|
||||
if (remainingBlocks.length > 0 && remainingBlocks[0].elements.length > 0) {
|
||||
newActiveElementId = remainingBlocks[0].elements[0].id;
|
||||
} else if (localSurvey.endings[0]) {
|
||||
newActiveElementId = localSurvey.endings[0].id;
|
||||
}
|
||||
|
||||
// Use functional update to ensure we work with the latest state
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const result = deleteBlock(prevSurvey, blockId);
|
||||
if (!result.ok) {
|
||||
// Return unchanged if block not found (shouldn't happen but be safe)
|
||||
return prevSurvey;
|
||||
}
|
||||
return result.data;
|
||||
});
|
||||
|
||||
// Set active element after queuing the survey update
|
||||
if (newActiveElementId) {
|
||||
setActiveElementId(newActiveElementId);
|
||||
}
|
||||
setLocalSurvey(result.data);
|
||||
toast.success(t("environments.surveys.edit.block_deleted"));
|
||||
};
|
||||
|
||||
const moveBlockById = (blockId: string, direction: "up" | "down") => {
|
||||
@@ -773,10 +765,7 @@ export const ElementsView = ({
|
||||
const [movedBlock] = blocks.splice(sourceBlockIndex, 1);
|
||||
blocks.splice(destBlockIndex, 0, movedBlock);
|
||||
|
||||
// Renumber blocks sequentially after drag-and-drop reordering
|
||||
const renumberedBlocks = renumberBlocks(blocks);
|
||||
|
||||
setLocalSurvey({ ...localSurvey, blocks: renumberedBlocks });
|
||||
setLocalSurvey({ ...localSurvey, blocks });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
|
||||
import { extractRecallInfo } from "@/lib/utils/recall";
|
||||
import { findVariableUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -132,9 +133,26 @@ export const SurveyVariablesCardItem = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// remove recall references from blocks
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const updatedBlocks = prevSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((element) => {
|
||||
const updatedHeadline = { ...element.headline };
|
||||
for (const [languageCode, headline] of Object.entries(element.headline)) {
|
||||
if (headline.includes(`recall:${variableToDelete.id}`)) {
|
||||
const recallInfo = extractRecallInfo(headline);
|
||||
if (recallInfo) {
|
||||
updatedHeadline[languageCode] = headline.replace(recallInfo, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...element, headline: updatedHeadline };
|
||||
}),
|
||||
}));
|
||||
|
||||
const updatedVariables = prevSurvey.variables.filter((v) => v.id !== variableToDelete.id);
|
||||
return { ...prevSurvey, variables: updatedVariables };
|
||||
return { ...prevSurvey, variables: updatedVariables, blocks: updatedBlocks };
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
isElementIdUnique,
|
||||
moveBlock,
|
||||
moveElementInBlock,
|
||||
renumberBlocks,
|
||||
updateBlock,
|
||||
updateElementInBlock,
|
||||
} from "./blocks";
|
||||
@@ -84,50 +83,6 @@ const createMockSurvey = (blocks: TSurveyBlock[] = []): TSurvey => ({
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
describe("renumberBlocks", () => {
|
||||
test("should renumber blocks sequentially starting from 1", () => {
|
||||
const blocks = [
|
||||
createMockBlock("block-1", "Old Name 1"),
|
||||
createMockBlock("block-2", "Old Name 2"),
|
||||
createMockBlock("block-3", "Old Name 3"),
|
||||
];
|
||||
|
||||
const result = renumberBlocks(blocks);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].name).toBe("Block 1");
|
||||
expect(result[1].name).toBe("Block 2");
|
||||
expect(result[2].name).toBe("Block 3");
|
||||
});
|
||||
|
||||
test("should preserve block IDs and other properties", () => {
|
||||
const blocks = [
|
||||
createMockBlock("block-1", "Old Name 1", [createMockElement("q1")]),
|
||||
createMockBlock("block-2", "Old Name 2", [createMockElement("q2")]),
|
||||
];
|
||||
|
||||
const result = renumberBlocks(blocks);
|
||||
|
||||
expect(result[0].id).toBe("block-1");
|
||||
expect(result[1].id).toBe("block-2");
|
||||
expect(result[0].elements).toHaveLength(1);
|
||||
expect(result[1].elements).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("should handle empty array", () => {
|
||||
const result = renumberBlocks([]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("should handle single block", () => {
|
||||
const blocks = [createMockBlock("block-1", "Old Name")];
|
||||
const result = renumberBlocks(blocks);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("Block 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isElementIdUnique", () => {
|
||||
test("should return true for a unique element ID", () => {
|
||||
const blocks = [
|
||||
@@ -196,12 +151,12 @@ describe("findElementLocation", () => {
|
||||
describe("addBlock", () => {
|
||||
test("should add a block to empty survey", () => {
|
||||
const survey = createMockSurvey([]);
|
||||
const result = addBlock(mockT, survey, { name: "Block 1" });
|
||||
const result = addBlock(mockT, survey, { name: "New Block" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.blocks).toHaveLength(1);
|
||||
expect(result.data.blocks[0].name).toBe("Block 1");
|
||||
expect(result.data.blocks[0].name).toBe("New Block");
|
||||
expect(result.data.blocks[0].elements).toEqual([]);
|
||||
}
|
||||
});
|
||||
@@ -227,9 +182,19 @@ describe("addBlock", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.blocks).toHaveLength(3);
|
||||
expect(result.data.blocks[1].name).toBe("Block 2");
|
||||
expect(result.data.blocks[1].name).toBe("Block 1.5");
|
||||
expect(result.data.blocks[0].name).toBe("Block 1");
|
||||
expect(result.data.blocks[2].name).toBe("Block 3");
|
||||
expect(result.data.blocks[2].name).toBe("Block 2");
|
||||
}
|
||||
});
|
||||
|
||||
test("should use default name if not provided", () => {
|
||||
const survey = createMockSurvey([]);
|
||||
const result = addBlock(mockT, survey, {});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.blocks[0].name).toBe("environments.surveys.edit.untitled_block");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -356,7 +321,7 @@ describe("duplicateBlock", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.blocks).toHaveLength(2);
|
||||
expect(result.data.blocks[1].name).toBe("Block 2");
|
||||
expect(result.data.blocks[1].name).toBe("Block 1 (copy)");
|
||||
expect(result.data.blocks[1].id).not.toBe("block-1");
|
||||
expect(result.data.blocks[1].elements[0].id).not.toBe("q1");
|
||||
expect(result.data.blocks[1].elements[1].id).not.toBe("q2");
|
||||
@@ -401,7 +366,7 @@ describe("duplicateBlock", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.blocks).toHaveLength(4);
|
||||
expect(result.data.blocks[2].name).toBe("Block 3");
|
||||
expect(result.data.blocks[2].name).toBe("Block 2 (copy)");
|
||||
expect(result.data.blocks[1].id).toBe("block-2");
|
||||
expect(result.data.blocks[3].id).toBe("block-3");
|
||||
}
|
||||
|
||||
@@ -52,19 +52,6 @@ export const findElementLocation = (
|
||||
// BLOCK OPERATIONS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Renumbers all blocks sequentially (Block 1, Block 2, Block 3, etc.)
|
||||
* This ensures block names stay in sync with their positions
|
||||
* @param blocks - Array of blocks to renumber
|
||||
* @returns Array of blocks with updated sequential names
|
||||
*/
|
||||
export const renumberBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => {
|
||||
return blocks.map((block, index) => ({
|
||||
...block,
|
||||
name: `Block ${index + 1}`,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new block to the survey. Always generates a new CUID for the block ID to prevent conflicts
|
||||
* @param survey - The survey to add the block to
|
||||
@@ -100,10 +87,7 @@ export const addBlock = (
|
||||
blocks.splice(index, 0, newBlock);
|
||||
}
|
||||
|
||||
// Renumber blocks sequentially after adding
|
||||
const renumberedBlocks = renumberBlocks(blocks);
|
||||
|
||||
updatedSurvey.blocks = renumberedBlocks;
|
||||
updatedSurvey.blocks = blocks;
|
||||
return ok(updatedSurvey);
|
||||
};
|
||||
|
||||
@@ -160,12 +144,9 @@ export const deleteBlock = (survey: TSurvey, blockId: string): Result<TSurvey, E
|
||||
return err(new Error(`Block with ID "${blockId}" not found`));
|
||||
}
|
||||
|
||||
// Renumber blocks sequentially after deletion
|
||||
const renumberedBlocks = renumberBlocks(filteredBlocks);
|
||||
|
||||
return ok({
|
||||
...survey,
|
||||
blocks: renumberedBlocks,
|
||||
blocks: filteredBlocks,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -192,7 +173,7 @@ export const duplicateBlock = (survey: TSurvey, blockId: string): Result<TSurvey
|
||||
|
||||
// Assign new IDs
|
||||
duplicatedBlock.id = createId();
|
||||
// Name will be set by renumberBlocks to maintain sequential naming
|
||||
duplicatedBlock.name = `${blockToDuplicate.name} (copy)`;
|
||||
|
||||
// Generate new element IDs to avoid conflicts
|
||||
duplicatedBlock.elements = duplicatedBlock.elements.map((element) => ({
|
||||
@@ -209,12 +190,9 @@ export const duplicateBlock = (survey: TSurvey, blockId: string): Result<TSurvey
|
||||
const updatedBlocks = [...blocks];
|
||||
updatedBlocks.splice(blockIndex + 1, 0, duplicatedBlock);
|
||||
|
||||
// Renumber blocks sequentially after duplication
|
||||
const renumberedBlocks = renumberBlocks(updatedBlocks);
|
||||
|
||||
return ok({
|
||||
...survey,
|
||||
blocks: renumberedBlocks,
|
||||
blocks: updatedBlocks,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -250,12 +228,9 @@ export const moveBlock = (
|
||||
// Swap using destructuring assignment
|
||||
[blocks[blockIndex], blocks[targetIndex]] = [blocks[targetIndex], blocks[blockIndex]];
|
||||
|
||||
// Renumber blocks sequentially after reordering
|
||||
const renumberedBlocks = renumberBlocks(blocks);
|
||||
|
||||
return ok({
|
||||
...survey,
|
||||
blocks: renumberedBlocks,
|
||||
blocks,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -91,13 +91,7 @@ describe("getProjectLanguages", () => {
|
||||
expect(languages).toEqual(mockProject.languages);
|
||||
expect(prisma.project.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "testProjectId" },
|
||||
select: {
|
||||
languages: {
|
||||
orderBy: {
|
||||
code: "asc",
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { languages: true },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,11 +28,7 @@ export const getProjectLanguages = reactCache(async (projectId: string): Promise
|
||||
id: projectId,
|
||||
},
|
||||
select: {
|
||||
languages: {
|
||||
orderBy: {
|
||||
code: "asc",
|
||||
},
|
||||
},
|
||||
languages: true,
|
||||
},
|
||||
});
|
||||
if (!project) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurveyCTAElement,
|
||||
TSurveyConsentElement,
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyRatingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
@@ -43,135 +36,72 @@ export const getPrefillValue = (
|
||||
return Object.keys(prefillAnswer).length > 0 ? prefillAnswer : undefined;
|
||||
};
|
||||
|
||||
const validateOpenText = (): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateMultipleChoiceSingle = (
|
||||
question: TSurveyMultipleChoiceElement,
|
||||
answer: string,
|
||||
language: string
|
||||
): boolean => {
|
||||
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) return false;
|
||||
const choices = question.choices;
|
||||
const hasOther = choices[choices.length - 1].id === "other";
|
||||
|
||||
if (!hasOther) {
|
||||
return choices.some((choice) => choice.label[language] === answer);
|
||||
}
|
||||
|
||||
const matchesAnyChoice = choices.some((choice) => choice.label[language] === answer);
|
||||
|
||||
if (matchesAnyChoice) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const trimmedAnswer = answer.trim();
|
||||
return trimmedAnswer !== "";
|
||||
};
|
||||
|
||||
const validateMultipleChoiceMulti = (question: TSurveyElement, answer: string, language: string): boolean => {
|
||||
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceMulti) return false;
|
||||
const choices = (
|
||||
question as TSurveyElement & { choices: Array<{ id: string; label: Record<string, string> }> }
|
||||
).choices;
|
||||
const hasOther = choices[choices.length - 1].id === "other";
|
||||
const lastChoiceLabel = hasOther ? choices[choices.length - 1].label[language] : undefined;
|
||||
|
||||
const answerChoices = answer
|
||||
.split(",")
|
||||
.map((ans) => ans.trim())
|
||||
.filter((ans) => ans !== "");
|
||||
|
||||
if (answerChoices.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasOther) {
|
||||
return answerChoices.every((ans: string) => choices.some((choice) => choice.label[language] === ans));
|
||||
}
|
||||
|
||||
let freeTextOtherCount = 0;
|
||||
for (const ans of answerChoices) {
|
||||
const matchesChoice = choices.some((choice) => choice.label[language] === ans);
|
||||
|
||||
if (matchesChoice) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ans === lastChoiceLabel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
freeTextOtherCount++;
|
||||
if (freeTextOtherCount > 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateNPS = (answer: string): boolean => {
|
||||
try {
|
||||
const cleanedAnswer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(cleanedAnswer));
|
||||
return !isNaN(answerNumber) && answerNumber >= 0 && answerNumber <= 10;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const validateCTA = (question: TSurveyCTAElement, answer: string): boolean => {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
return answer === "clicked" || answer === "dismissed";
|
||||
};
|
||||
|
||||
const validateConsent = (question: TSurveyConsentElement, answer: string): boolean => {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
return answer === "accepted" || answer === "dismissed";
|
||||
};
|
||||
|
||||
const validateRating = (question: TSurveyRatingElement, answer: string): boolean => {
|
||||
if (question.type !== TSurveyElementTypeEnum.Rating) return false;
|
||||
const ratingQuestion = question;
|
||||
try {
|
||||
const cleanedAnswer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(cleanedAnswer));
|
||||
return answerNumber >= 1 && answerNumber <= (ratingQuestion.range ?? 5);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const validatePictureSelection = (answer: string): boolean => {
|
||||
const answerChoices = answer.split(",");
|
||||
return answerChoices.every((ans: string) => !isNaN(Number(ans)));
|
||||
};
|
||||
|
||||
const checkValidity = (question: TSurveyElement, answer: string, language: string): boolean => {
|
||||
if (question.required && (!answer || answer === "")) return false;
|
||||
|
||||
const validators: Partial<
|
||||
Record<TSurveyElementTypeEnum, (q: TSurveyElement, a: string, l: string) => boolean>
|
||||
> = {
|
||||
[TSurveyElementTypeEnum.OpenText]: () => validateOpenText(),
|
||||
[TSurveyElementTypeEnum.MultipleChoiceSingle]: (q, a, l) =>
|
||||
validateMultipleChoiceSingle(q as TSurveyMultipleChoiceElement, a, l),
|
||||
[TSurveyElementTypeEnum.MultipleChoiceMulti]: (q, a, l) => validateMultipleChoiceMulti(q, a, l),
|
||||
[TSurveyElementTypeEnum.NPS]: (_, a) => validateNPS(a),
|
||||
[TSurveyElementTypeEnum.CTA]: (q, a) => validateCTA(q as TSurveyCTAElement, a),
|
||||
[TSurveyElementTypeEnum.Consent]: (q, a) => validateConsent(q as TSurveyConsentElement, a),
|
||||
[TSurveyElementTypeEnum.Rating]: (q, a) => validateRating(q as TSurveyRatingElement, a),
|
||||
[TSurveyElementTypeEnum.PictureSelection]: (_, a) => validatePictureSelection(a),
|
||||
};
|
||||
|
||||
const validator = validators[question.type];
|
||||
if (!validator) return false;
|
||||
|
||||
try {
|
||||
return validator(question, answer, language);
|
||||
} catch {
|
||||
switch (question.type) {
|
||||
case TSurveyElementTypeEnum.OpenText: {
|
||||
return true;
|
||||
}
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle: {
|
||||
const hasOther = question.choices[question.choices.length - 1].id === "other";
|
||||
if (!hasOther) {
|
||||
if (!question.choices.find((choice) => choice.label[language] === answer)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (question.choices[question.choices.length - 1].label[language] === answer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
|
||||
const answerChoices = answer.split(",");
|
||||
const hasOther = question.choices[question.choices.length - 1].id === "other";
|
||||
if (!hasOther) {
|
||||
if (
|
||||
!answerChoices.every((ans: string) =>
|
||||
question.choices.find((choice) => choice.label[language] === ans)
|
||||
)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case TSurveyElementTypeEnum.NPS: {
|
||||
const cleanedAnswer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(cleanedAnswer));
|
||||
|
||||
if (isNaN(answerNumber)) return false;
|
||||
if (answerNumber < 0 || answerNumber > 10) return false;
|
||||
return true;
|
||||
}
|
||||
case TSurveyElementTypeEnum.CTA: {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
if (answer !== "clicked" && answer !== "dismissed") return false;
|
||||
return true;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Consent: {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
if (answer !== "accepted" && answer !== "dismissed") return false;
|
||||
return true;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Rating: {
|
||||
const cleanedAnswer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(cleanedAnswer));
|
||||
if (answerNumber < 1 || answerNumber > question.range) return false;
|
||||
return true;
|
||||
}
|
||||
case TSurveyElementTypeEnum.PictureSelection: {
|
||||
const answerChoices = answer.split(",");
|
||||
return answerChoices.every((ans: string) => !isNaN(Number(ans)));
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
|
||||
"generate-api-specs": "./scripts/openapi/generate.sh",
|
||||
"merge-client-endpoints": "tsx ./scripts/openapi/merge-client-endpoints.ts",
|
||||
"generate-and-merge-api-specs": "pnpm run generate-api-specs && pnpm run merge-client-endpoints",
|
||||
"i18n:generate": "npx lingo.dev@latest i18n"
|
||||
"generate-and-merge-api-specs": "pnpm run generate-api-specs && pnpm run merge-client-endpoints"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.879.0",
|
||||
|
||||
@@ -232,7 +232,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
for (let i = 0; i < surveys.createAndSubmit.ranking.choices.length; i++) {
|
||||
await page.getByText(surveys.createAndSubmit.ranking.choices[i]).click();
|
||||
}
|
||||
await page.locator("#questionCard-12").getByRole("button", { name: "Finish" }).click();
|
||||
await page.locator("#questionCard-12").getByRole("button", { name: "Next" }).click();
|
||||
// loading spinner -> wait for it to disappear
|
||||
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
|
||||
});
|
||||
@@ -979,7 +979,7 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
.fill("This is my city");
|
||||
await expect(page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.zip)).toBeVisible();
|
||||
await page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.zip).fill("12345");
|
||||
await page.locator("#questionCard-13").getByRole("button", { name: "Finish" }).click();
|
||||
await page.locator("#questionCard-13").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// loading spinner -> wait for it to disappear
|
||||
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
|
||||
|
||||
@@ -234,7 +234,6 @@
|
||||
"self-hosting/configuration/smtp",
|
||||
"self-hosting/configuration/file-uploads",
|
||||
"self-hosting/configuration/domain-configuration",
|
||||
"self-hosting/configuration/custom-subpath",
|
||||
{
|
||||
"group": "Auth & SSO",
|
||||
"icon": "lock",
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
title: "Custom Subpath"
|
||||
description: "Serve Formbricks from a custom URL prefix when you cannot expose it on the root domain."
|
||||
icon: "link"
|
||||
---
|
||||
|
||||
<Note>
|
||||
Custom subpath deployments are currently under internal review. If you need early access, please reach out via
|
||||
[GitHub Discussions](https://github.com/formbricks/formbricks/discussions).
|
||||
</Note>
|
||||
|
||||
### When to use a custom subpath
|
||||
|
||||
Use a custom subpath (also called a Next.js base path) when your reverse proxy reserves the root domain for another
|
||||
service, but you still want Formbricks to live under the same hostname—for example `https://example.com/feedback`.
|
||||
Support for a build-time `BASE_PATH` variable is available in the Formbricks web app so that all internal routes,
|
||||
assets, and sign-in redirects honor the prefix.
|
||||
|
||||
### Requirements and limitations
|
||||
|
||||
- `BASE_PATH` must be present during `pnpm build`; changing it afterward requires a rebuild.
|
||||
- Official Formbricks Docker images do **not** accept this flag for technical reasons, so you must build your own image.
|
||||
- All public URLs (`WEBAPP_URL`, `NEXTAUTH_URL`, webhook targets, OAuth callbacks, etc.) need the same prefix.
|
||||
- Your proxy must rewrite `/custom-path/*` to the Formbricks container while keeping the prefix visible to clients.
|
||||
|
||||
### Configure environment variables
|
||||
|
||||
Add the following variables to the environment you use for builds (local, CI, or Docker build args):
|
||||
|
||||
```bash
|
||||
BASE_PATH="/custom-path"
|
||||
WEBAPP_URL="https://yourdomain.com/custom-path"
|
||||
NEXTAUTH_URL="https://yourdomain.com/custom-path/api/auth"
|
||||
```
|
||||
|
||||
If you use email links, webhooks, or third-party OAuth providers, ensure every URL you register includes the prefix.
|
||||
|
||||
### Build a Docker image with a custom subpath
|
||||
|
||||
<Steps>
|
||||
<Step title="Clone Formbricks and prepare secrets">
|
||||
Make sure you have the repository checked out and create temporary files (or use <code>--secret</code>) for the
|
||||
required build-time secrets such as <code>DATABASE_URL</code>, <code>ENCRYPTION_KEY</code>, <code>REDIS_URL</code>,
|
||||
and optional telemetry tokens.
|
||||
</Step>
|
||||
<Step title="Pass BASE_PATH as a build argument">
|
||||
Use the Formbricks web Dockerfile and supply the custom subpath via <code>--build-arg</code>. Example:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--progress=plain \
|
||||
--no-cache \
|
||||
--build-arg BASE_PATH=/custom-path \
|
||||
--secret id=database_url,src=<(printf "postgresql://user:password@localhost:5432/formbricks?schema=public") \
|
||||
--secret id=encryption_key,src=<(printf "your-32-character-encryption-key-here") \
|
||||
--secret id=redis_url,src=<(printf "redis://localhost:6379") \
|
||||
--secret id=sentry_auth_token,src=<(printf "") \
|
||||
-f apps/web/Dockerfile \
|
||||
-t formbricks-web \
|
||||
.
|
||||
```
|
||||
|
||||
During the build logs you should see <code>BASE PATH /custom-path</code>, confirming that Next.js picked up the
|
||||
prefix.
|
||||
</Step>
|
||||
<Step title="Run the container behind your proxy">
|
||||
Start the resulting image with the same runtime environment variables you normally use (database credentials,
|
||||
mailing provider, etc.). Point your reverse proxy so that <code>/custom-path</code> requests forward to
|
||||
<code>http://formbricks-web:3000/custom-path</code> without stripping the prefix.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Verify the deployment
|
||||
|
||||
1. Open `https://yourdomain.com/custom-path` and complete the onboarding flow.
|
||||
2. Create a survey and preview it—embedded scripts now load assets relative to the subpath.
|
||||
3. Sign out and confirm the login page still includes `/custom-path`.
|
||||
|
||||
### Troubleshooting checklist
|
||||
|
||||
- Confirm your build pipeline actually passes `BASE_PATH` (and, if needed, `WEBAPP_URL`/`NEXTAUTH_URL`) into the build
|
||||
stage—check CI logs for the `BASE PATH /your-prefix` line and make sure custom Dockerfiles or wrappers forward
|
||||
`--build-arg BASE_PATH=...` correctly.
|
||||
- If you cannot log in, double-check that `NEXTAUTH_URL` includes the prefix and uses the full route to the API as stated above. NextAuth rejects callbacks that do not
|
||||
match exactly.
|
||||
- Re-run the Docker build when changing `BASE_PATH`; simply editing the container environment is not sufficient.
|
||||
- Inspect your proxy configuration to ensure it does not rewrite paths internally (e.g., `strip_prefix` needs to stay
|
||||
disabled).
|
||||
- When in doubt, rebuild locally with `--progress=plain` and verify that the `BASE PATH` line reflects your prefix.
|
||||
|
||||
+2
-3
@@ -34,9 +34,8 @@
|
||||
"prepare": "husky install",
|
||||
"storybook": "turbo run storybook",
|
||||
"fb-migrate-dev": "pnpm --filter @formbricks/database create-migration && pnpm prisma generate",
|
||||
"i18n:surveys:generate": "pnpm --filter @formbricks/surveys i18n:generate",
|
||||
"i18n:web:generate": "pnpm --filter @formbricks/web i18n:generate",
|
||||
"generate-translations": "pnpm i18n:web:generate && pnpm i18n:surveys:generate",
|
||||
"i18n:generate": " pnpm --filter @formbricks/surveys i18n:generate",
|
||||
"generate-translations": "cd apps/web && npx lingo.dev@latest i18n",
|
||||
"scan-translations": "pnpm --filter @formbricks/i18n-utils scan-translations",
|
||||
"i18n": "pnpm generate-translations && pnpm scan-translations",
|
||||
"i18n:validate": "pnpm scan-translations"
|
||||
|
||||
Vendored
+8
-52
@@ -3,7 +3,7 @@ import type { RedisClient } from "@/types/client";
|
||||
import { type CacheError, CacheErrorClass, ErrorCode, type Result, err, ok } from "@/types/error";
|
||||
import type { CacheKey } from "@/types/keys";
|
||||
import { ZCacheKey } from "@/types/keys";
|
||||
import { ZTtlMs, ZTtlMsOptional } from "@/types/service";
|
||||
import { ZTtlMs } from "@/types/service";
|
||||
import { validateInputs } from "./utils/validation";
|
||||
|
||||
/**
|
||||
@@ -116,13 +116,13 @@ export class CacheService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in cache with automatic JSON serialization and optional TTL
|
||||
* Set a value in cache with automatic JSON serialization and TTL
|
||||
* @param key - Cache key to store under
|
||||
* @param value - Value to store
|
||||
* @param ttlMs - Time to live in milliseconds (optional - if omitted, key persists indefinitely)
|
||||
* @param ttlMs - Time to live in milliseconds
|
||||
* @returns Result containing void or an error
|
||||
*/
|
||||
async set(key: CacheKey, value: unknown, ttlMs?: number): Promise<Result<void, CacheError>> {
|
||||
async set(key: CacheKey, value: unknown, ttlMs: number): Promise<Result<void, CacheError>> {
|
||||
// Check Redis availability first
|
||||
if (!this.isRedisClientReady()) {
|
||||
return err({
|
||||
@@ -130,8 +130,8 @@ export class CacheService {
|
||||
});
|
||||
}
|
||||
|
||||
// Validate key and optional TTL
|
||||
const validation = validateInputs([key, ZCacheKey], [ttlMs, ZTtlMsOptional]);
|
||||
// Validate both key and TTL in one call
|
||||
const validation = validateInputs([key, ZCacheKey], [ttlMs, ZTtlMs]);
|
||||
if (!validation.ok) {
|
||||
return validation;
|
||||
}
|
||||
@@ -141,13 +141,7 @@ export class CacheService {
|
||||
const normalizedValue = value === undefined ? null : value;
|
||||
const serialized = JSON.stringify(normalizedValue);
|
||||
|
||||
if (ttlMs === undefined) {
|
||||
// Set without expiration (persists indefinitely)
|
||||
await this.withTimeout(this.redis.set(key, serialized));
|
||||
} else {
|
||||
// Set with expiration
|
||||
await this.withTimeout(this.redis.setEx(key, Math.floor(ttlMs / 1000), serialized));
|
||||
}
|
||||
await this.withTimeout(this.redis.setEx(key, Math.floor(ttlMs / 1000), serialized));
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
logger.error({ error, key, ttlMs }, "Cache set operation failed");
|
||||
@@ -191,44 +185,6 @@ export class CacheService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to acquire a distributed lock (atomic SET NX operation)
|
||||
* @param key - Lock key
|
||||
* @param value - Lock value (typically "locked" or instance identifier)
|
||||
* @param ttlMs - Time to live in milliseconds (lock expiration)
|
||||
* @returns Result containing boolean indicating if lock was acquired, or an error
|
||||
*/
|
||||
async tryLock(key: CacheKey, value: string, ttlMs: number): Promise<Result<boolean, CacheError>> {
|
||||
// Check Redis availability first
|
||||
if (!this.isRedisClientReady()) {
|
||||
return err({
|
||||
code: ErrorCode.RedisConnectionError,
|
||||
});
|
||||
}
|
||||
|
||||
const validation = validateInputs([key, ZCacheKey], [ttlMs, ZTtlMs]);
|
||||
if (!validation.ok) {
|
||||
return validation;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use SET with NX (only set if not exists) and PX (expiration in milliseconds) for atomic lock acquisition
|
||||
const result = await this.withTimeout(
|
||||
this.redis.set(key, value, {
|
||||
NX: true,
|
||||
PX: ttlMs,
|
||||
})
|
||||
);
|
||||
// SET returns "OK" if lock was acquired, null if key already exists
|
||||
return ok(result === "OK");
|
||||
} catch (error) {
|
||||
logger.error({ error, key, ttlMs }, "Cache lock operation failed");
|
||||
return err({
|
||||
code: ErrorCode.RedisOperationError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache wrapper for functions (cache-aside).
|
||||
* Never throws due to cache errors; function errors propagate without retry.
|
||||
@@ -287,7 +243,7 @@ export class CacheService {
|
||||
}
|
||||
|
||||
private async trySetCache(key: CacheKey, value: unknown, ttlMs: number): Promise<void> {
|
||||
if (value === undefined) {
|
||||
if (typeof value === "undefined") {
|
||||
return; // Skip caching undefined values
|
||||
}
|
||||
|
||||
|
||||
Vendored
-7
@@ -5,10 +5,3 @@ export const ZTtlMs = z
|
||||
.int()
|
||||
.min(1000, "TTL must be at least 1000ms (1 second)")
|
||||
.finite("TTL must be finite");
|
||||
|
||||
export const ZTtlMsOptional = z
|
||||
.number()
|
||||
.int()
|
||||
.min(1000, "TTL must be at least 1000ms (1 second)")
|
||||
.finite("TTL must be finite")
|
||||
.optional();
|
||||
|
||||
+55
-189
@@ -1,15 +1,8 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { MigrationScript } from "../../src/scripts/migration-runner";
|
||||
import type {
|
||||
Block,
|
||||
CTAMigrationStats,
|
||||
IntegrationConfig,
|
||||
IntegrationMigrationStats,
|
||||
MigratedIntegration,
|
||||
SurveyRecord,
|
||||
} from "./types";
|
||||
import { migrateIntegrationConfig, migrateQuestionsSurveyToBlocks } from "./utils";
|
||||
import type { Block, CTAMigrationStats, SurveyRecord } from "./types";
|
||||
import { migrateQuestionsSurveyToBlocks } from "./utils";
|
||||
|
||||
export const migrateQuestionsToBlocks: MigrationScript = {
|
||||
type: "data",
|
||||
@@ -32,198 +25,71 @@ export const migrateQuestionsToBlocks: MigrationScript = {
|
||||
|
||||
if (surveys.length === 0) {
|
||||
logger.info("No surveys found that need migration");
|
||||
} else {
|
||||
logger.info(`Found ${surveys.length.toString()} surveys to migrate`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Process each survey
|
||||
const updates: { id: string; blocks: Block[] }[] = [];
|
||||
logger.info(`Found ${surveys.length.toString()} surveys to migrate`);
|
||||
|
||||
for (const survey of surveys) {
|
||||
try {
|
||||
const migrated = migrateQuestionsSurveyToBlocks(survey, createId, ctaStats);
|
||||
updates.push({
|
||||
id: migrated.id,
|
||||
blocks: migrated.blocks,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to migrate survey ${survey.id}`);
|
||||
throw new Error(
|
||||
`Migration failed for survey ${survey.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
// 2. Process each survey
|
||||
const updates: { id: string; blocks: Block[] }[] = [];
|
||||
|
||||
logger.info(`Successfully processed ${updates.length.toString()} surveys`);
|
||||
|
||||
// 3. Update surveys in batches using UNNEST for performance
|
||||
// Batch size of 150 balances performance with query size safety (~7.5MB per batch)
|
||||
const SURVEY_BATCH_SIZE = 150;
|
||||
let updatedCount = 0;
|
||||
|
||||
for (let i = 0; i < updates.length; i += SURVEY_BATCH_SIZE) {
|
||||
const batch = updates.slice(i, i + SURVEY_BATCH_SIZE);
|
||||
|
||||
try {
|
||||
// Build arrays for batch update
|
||||
const ids = batch.map((u) => u.id);
|
||||
const blocksJsonStrings = batch.map((u) => JSON.stringify(u.blocks));
|
||||
|
||||
// Use UNNEST to update multiple surveys in a single query
|
||||
await tx.$executeRawUnsafe(
|
||||
`UPDATE "Survey" AS s
|
||||
SET
|
||||
blocks = (
|
||||
SELECT array_agg(elem)
|
||||
FROM jsonb_array_elements(data.blocks_json::jsonb) AS elem
|
||||
),
|
||||
questions = '[]'::jsonb
|
||||
FROM (
|
||||
SELECT
|
||||
unnest($1::text[]) AS id,
|
||||
unnest($2::text[]) AS blocks_json
|
||||
) AS data
|
||||
WHERE s.id = data.id`,
|
||||
ids,
|
||||
blocksJsonStrings
|
||||
);
|
||||
|
||||
updatedCount += batch.length;
|
||||
|
||||
// Log progress
|
||||
logger.info(`Progress: ${updatedCount.toString()}/${updates.length.toString()} surveys updated`);
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to update survey batch starting at index ${i.toString()}`);
|
||||
throw new Error(
|
||||
`Database batch update failed at index ${i.toString()}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Migration complete: ${updatedCount.toString()} surveys migrated to blocks`);
|
||||
|
||||
// 4. Log CTA migration statistics
|
||||
if (ctaStats.totalCTAElements > 0) {
|
||||
logger.info(
|
||||
`CTA elements processed: ${ctaStats.totalCTAElements.toString()} total (${ctaStats.ctaWithExternalLink.toString()} with external link, ${ctaStats.ctaWithoutExternalLink.toString()} without)`
|
||||
for (const survey of surveys) {
|
||||
try {
|
||||
const migrated = migrateQuestionsSurveyToBlocks(survey, createId, ctaStats);
|
||||
updates.push({
|
||||
id: migrated.id,
|
||||
blocks: migrated.blocks,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to migrate survey ${survey.id}`);
|
||||
throw new Error(
|
||||
`Migration failed for survey ${survey.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Migrate Integration configs
|
||||
logger.info("Starting integration config migration");
|
||||
logger.info(`Successfully processed ${updates.length.toString()} surveys`);
|
||||
|
||||
// Initialize integration statistics
|
||||
const integrationStats: IntegrationMigrationStats = {
|
||||
totalIntegrations: 0,
|
||||
googleSheets: { processed: 0, skipped: 0 },
|
||||
airtable: { processed: 0, skipped: 0 },
|
||||
slack: { processed: 0, skipped: 0 },
|
||||
notion: { processed: 0, skipped: 0 },
|
||||
n8n: { skipped: 0 },
|
||||
errors: 0,
|
||||
};
|
||||
// 3. Update surveys individually for safety (avoids SQL injection risks with complex JSONB arrays)
|
||||
let updatedCount = 0;
|
||||
|
||||
// Query all integrations
|
||||
const integrations = await tx.$queryRaw<{ id: string; type: string; config: IntegrationConfig }[]>`
|
||||
SELECT id, type, config
|
||||
FROM "Integration"
|
||||
`;
|
||||
|
||||
integrationStats.totalIntegrations = integrations.length;
|
||||
|
||||
if (integrations.length === 0) {
|
||||
logger.info("No integrations found to migrate");
|
||||
} else {
|
||||
logger.info(`Found ${integrations.length.toString()} integrations to process`);
|
||||
|
||||
// Process integrations in memory
|
||||
const integrationUpdates: MigratedIntegration[] = [];
|
||||
|
||||
for (const integration of integrations) {
|
||||
try {
|
||||
// Config is JSON from database - cast to IntegrationConfig for runtime processing
|
||||
const result = migrateIntegrationConfig(integration.type, integration.config);
|
||||
|
||||
// Track statistics
|
||||
const typeStats = integrationStats[integration.type as keyof typeof integrationStats];
|
||||
if (typeStats && typeof typeStats === "object" && "processed" in typeStats) {
|
||||
if (result.migrated) {
|
||||
typeStats.processed++;
|
||||
integrationUpdates.push({
|
||||
id: integration.id,
|
||||
config: result.config,
|
||||
});
|
||||
} else {
|
||||
typeStats.skipped++;
|
||||
}
|
||||
} else if (integration.type === "n8n") {
|
||||
integrationStats.n8n.skipped++;
|
||||
}
|
||||
} catch (error) {
|
||||
integrationStats.errors++;
|
||||
logger.error(error, `Failed to migrate integration ${integration.id} (type: ${integration.type})`);
|
||||
throw new Error(
|
||||
`Migration failed for integration ${integration.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Processed ${integrations.length.toString()} integrations: ${integrationUpdates.length.toString()} to update, ${(integrations.length - integrationUpdates.length).toString()} skipped`
|
||||
);
|
||||
|
||||
// Update integrations using Promise.all for better throughput
|
||||
if (integrationUpdates.length > 0) {
|
||||
// Batch size of 150 provides good parallelization (~750KB per batch)
|
||||
const INTEGRATION_BATCH_SIZE = 150;
|
||||
let integrationUpdatedCount = 0;
|
||||
|
||||
for (let i = 0; i < integrationUpdates.length; i += INTEGRATION_BATCH_SIZE) {
|
||||
const batch = integrationUpdates.slice(i, i + INTEGRATION_BATCH_SIZE);
|
||||
|
||||
try {
|
||||
// Execute all updates in parallel for this batch
|
||||
await Promise.all(
|
||||
batch.map((update) =>
|
||||
tx.$executeRawUnsafe(
|
||||
`UPDATE "Integration"
|
||||
SET config = $1::jsonb
|
||||
WHERE id = $2`,
|
||||
JSON.stringify(update.config),
|
||||
update.id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
integrationUpdatedCount += batch.length;
|
||||
|
||||
// Log progress
|
||||
logger.info(
|
||||
`Integration progress: ${integrationUpdatedCount.toString()}/${integrationUpdates.length.toString()} updated`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to update integration batch starting at index ${i.toString()}`);
|
||||
throw new Error(
|
||||
`Database update failed for integration batch at index ${i.toString()}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Integration migration complete: ${integrationUpdatedCount.toString()} integrations updated`
|
||||
for (const update of updates) {
|
||||
try {
|
||||
// PostgreSQL requires proper array format for jsonb[]
|
||||
// We need to convert the JSON array to a PostgreSQL jsonb array using array_to_json
|
||||
// The trick is to use jsonb_array_elements to convert the JSON array into rows, then array_agg to collect them back
|
||||
await tx.$executeRawUnsafe(
|
||||
`UPDATE "Survey"
|
||||
SET blocks = (
|
||||
SELECT array_agg(elem)
|
||||
FROM jsonb_array_elements($1::jsonb) AS elem
|
||||
),
|
||||
questions = '[]'::jsonb
|
||||
WHERE id = $2`,
|
||||
JSON.stringify(update.blocks),
|
||||
update.id
|
||||
);
|
||||
} else {
|
||||
logger.info("No integrations needed updating (all already migrated or skipped)");
|
||||
}
|
||||
|
||||
// Log detailed statistics
|
||||
updatedCount++;
|
||||
|
||||
// Log progress every 10000 surveys
|
||||
if (updatedCount % 10000 === 0) {
|
||||
logger.info(`Progress: ${updatedCount.toString()}/${updates.length.toString()} surveys updated`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to update survey ${update.id} in database`);
|
||||
throw new Error(
|
||||
`Database update failed for survey ${update.id}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Migration complete: ${updatedCount.toString()} surveys migrated to blocks`);
|
||||
|
||||
// 4. Log CTA migration statistics
|
||||
if (ctaStats.totalCTAElements > 0) {
|
||||
logger.info(
|
||||
`Integration statistics: ` +
|
||||
`GoogleSheets: ${integrationStats.googleSheets.processed.toString()} migrated, ${integrationStats.googleSheets.skipped.toString()} skipped | ` +
|
||||
`Airtable: ${integrationStats.airtable.processed.toString()} migrated, ${integrationStats.airtable.skipped.toString()} skipped | ` +
|
||||
`Slack: ${integrationStats.slack.processed.toString()} migrated, ${integrationStats.slack.skipped.toString()} skipped | ` +
|
||||
`Notion: ${integrationStats.notion.processed.toString()} migrated, ${integrationStats.notion.skipped.toString()} skipped | ` +
|
||||
`n8n: ${integrationStats.n8n.skipped.toString()} skipped`
|
||||
`CTA elements processed: ${ctaStats.totalCTAElements.toString()} total (${ctaStats.ctaWithExternalLink.toString()} with external link, ${ctaStats.ctaWithoutExternalLink.toString()} without)`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,151 +76,6 @@ export interface CTAMigrationStats {
|
||||
ctaWithoutExternalLink: number;
|
||||
}
|
||||
|
||||
// Base integration config data (shared between all integrations except Notion)
|
||||
// This represents both old (questionIds/questions) and new (elementIds/elements) formats
|
||||
export interface IntegrationBaseSurveyData {
|
||||
createdAt: Date;
|
||||
surveyId: string;
|
||||
surveyName: string;
|
||||
// Old format fields
|
||||
questionIds?: string[];
|
||||
questions?: string;
|
||||
// New format fields
|
||||
elementIds?: string[];
|
||||
elements?: string;
|
||||
// Optional fields
|
||||
includeVariables?: boolean;
|
||||
includeHiddenFields?: boolean;
|
||||
includeMetadata?: boolean;
|
||||
includeCreatedAt?: boolean;
|
||||
}
|
||||
|
||||
// Google Sheets specific config
|
||||
export interface GoogleSheetsConfigData extends IntegrationBaseSurveyData {
|
||||
spreadsheetId: string;
|
||||
spreadsheetName: string;
|
||||
}
|
||||
|
||||
export interface GoogleSheetsConfig {
|
||||
key: {
|
||||
token_type: "Bearer";
|
||||
access_token: string;
|
||||
scope: string;
|
||||
expiry_date: number;
|
||||
refresh_token: string;
|
||||
};
|
||||
data: GoogleSheetsConfigData[];
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Airtable specific config
|
||||
export interface AirtableConfigData extends IntegrationBaseSurveyData {
|
||||
tableId: string;
|
||||
baseId: string;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
export interface AirtableConfig {
|
||||
key: {
|
||||
expiry_date: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
data: AirtableConfigData[];
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Slack specific config
|
||||
export interface SlackConfigData extends IntegrationBaseSurveyData {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
export interface SlackConfig {
|
||||
key: {
|
||||
app_id: string;
|
||||
authed_user: { id: string };
|
||||
token_type: "bot";
|
||||
access_token: string;
|
||||
bot_user_id: string;
|
||||
team: { id: string; name: string };
|
||||
};
|
||||
data: SlackConfigData[];
|
||||
}
|
||||
|
||||
// Notion specific config (different structure - uses mapping instead of elementIds/elements)
|
||||
export interface NotionMappingItem {
|
||||
// Old format
|
||||
question?: { id: string; name: string; type: string };
|
||||
// New format
|
||||
element?: { id: string; name: string; type: string };
|
||||
column: { id: string; name: string; type: string };
|
||||
}
|
||||
|
||||
export interface NotionConfigData {
|
||||
createdAt: Date;
|
||||
surveyId: string;
|
||||
surveyName: string;
|
||||
mapping: NotionMappingItem[];
|
||||
databaseId: string;
|
||||
databaseName: string;
|
||||
}
|
||||
|
||||
export interface NotionConfig {
|
||||
key: {
|
||||
access_token: string;
|
||||
bot_id: string;
|
||||
token_type: string;
|
||||
duplicated_template_id: string | null;
|
||||
owner: {
|
||||
type: string;
|
||||
workspace?: boolean | null;
|
||||
user: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
type?: string | null;
|
||||
object: string;
|
||||
person?: { email: string } | null;
|
||||
avatar_url?: string | null;
|
||||
} | null;
|
||||
};
|
||||
workspace_icon: string | null;
|
||||
workspace_id: string;
|
||||
workspace_name: string | null;
|
||||
};
|
||||
data: NotionConfigData[];
|
||||
}
|
||||
|
||||
// Union type for all integration configs
|
||||
export type IntegrationConfig =
|
||||
| GoogleSheetsConfig
|
||||
| AirtableConfig
|
||||
| SlackConfig
|
||||
| NotionConfig
|
||||
| Record<string, unknown>;
|
||||
|
||||
// Integration migration types
|
||||
export interface IntegrationRecord {
|
||||
id: string;
|
||||
type: string;
|
||||
config: IntegrationConfig;
|
||||
}
|
||||
|
||||
export interface MigratedIntegration {
|
||||
id: string;
|
||||
config: IntegrationConfig;
|
||||
}
|
||||
|
||||
export interface IntegrationMigrationStats {
|
||||
totalIntegrations: number;
|
||||
googleSheets: { processed: number; skipped: number };
|
||||
airtable: { processed: number; skipped: number };
|
||||
slack: { processed: number; skipped: number };
|
||||
notion: { processed: number; skipped: number };
|
||||
n8n: { skipped: number };
|
||||
errors: number;
|
||||
}
|
||||
|
||||
// Type guards
|
||||
export const isSingleCondition = (condition: Condition): condition is SingleCondition => {
|
||||
return "leftOperand" in condition && "operator" in condition;
|
||||
|
||||
@@ -3,10 +3,8 @@ import {
|
||||
type CTAMigrationStats,
|
||||
type Condition,
|
||||
type ConditionGroup,
|
||||
type IntegrationConfig,
|
||||
type LogicAction,
|
||||
type MigratedSurvey,
|
||||
type NotionConfig,
|
||||
type SingleCondition,
|
||||
type SurveyLogic,
|
||||
type SurveyQuestion,
|
||||
@@ -416,198 +414,3 @@ export const migrateQuestionsSurveyToBlocks = (
|
||||
blocks,
|
||||
};
|
||||
};
|
||||
|
||||
// Type guard for config items with data array
|
||||
interface ConfigWithData {
|
||||
data: Record<string, unknown>[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const hasDataArray = (config: unknown): config is ConfigWithData => {
|
||||
return (
|
||||
typeof config === "object" &&
|
||||
config !== null &&
|
||||
"data" in config &&
|
||||
Array.isArray((config as ConfigWithData).data)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if config item is already migrated (has elementIds/elements)
|
||||
*/
|
||||
const isAlreadyMigrated = (item: Record<string, unknown>): boolean => {
|
||||
return "elementIds" in item || "elements" in item;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if config item needs migration (has questionIds/questions)
|
||||
*/
|
||||
const needsMigration = (item: Record<string, unknown>): boolean => {
|
||||
return "questionIds" in item || "questions" in item;
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate Airtable/Google Sheets/Slack config (shared base type)
|
||||
* Returns an object with migrated flag and updated config
|
||||
*/
|
||||
export const migrateSharedIntegrationConfig = (
|
||||
config: IntegrationConfig
|
||||
): { migrated: boolean; config: IntegrationConfig } => {
|
||||
// Validate config structure
|
||||
if (!hasDataArray(config)) {
|
||||
return { migrated: false, config };
|
||||
}
|
||||
|
||||
let anyMigrated = false;
|
||||
|
||||
const newData = config.data.map((item) => {
|
||||
// Skip if already migrated
|
||||
if (isAlreadyMigrated(item)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
// Skip if nothing to migrate
|
||||
if (!needsMigration(item)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
anyMigrated = true;
|
||||
const migrated: Record<string, unknown> = { ...item };
|
||||
|
||||
// Rename questionIds to elementIds
|
||||
if ("questionIds" in migrated) {
|
||||
migrated.elementIds = migrated.questionIds;
|
||||
delete migrated.questionIds;
|
||||
}
|
||||
|
||||
// Rename questions to elements
|
||||
if ("questions" in migrated) {
|
||||
migrated.elements = migrated.questions;
|
||||
delete migrated.questions;
|
||||
}
|
||||
|
||||
// All other fields (includeVariables, etc.) are preserved automatically via spread
|
||||
|
||||
return migrated;
|
||||
});
|
||||
|
||||
return {
|
||||
migrated: anyMigrated,
|
||||
config: { ...config, data: newData },
|
||||
};
|
||||
};
|
||||
|
||||
// Type guard for Notion config
|
||||
const isNotionConfig = (config: unknown): config is NotionConfig => {
|
||||
return (
|
||||
typeof config === "object" &&
|
||||
config !== null &&
|
||||
"data" in config &&
|
||||
Array.isArray((config as NotionConfig).data)
|
||||
);
|
||||
};
|
||||
|
||||
// Type for Notion mapping entry
|
||||
interface NotionMappingEntry {
|
||||
question?: { id: string; name: string; type: string };
|
||||
element?: { id: string; name: string; type: string };
|
||||
column: { id: string; name: string; type: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Notion config item has any mapping entries that need migration
|
||||
* @param mapping - Notion mapping entries
|
||||
* @returns boolean
|
||||
*/
|
||||
const needsNotionMigration = (mapping: NotionMappingEntry[] | undefined): boolean => {
|
||||
if (!mapping || !Array.isArray(mapping) || mapping.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if ANY mapping item has "question" field (needs migration)
|
||||
return mapping.some((mapItem) => "question" in mapItem && !("element" in mapItem));
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate Notion config (custom mapping structure)
|
||||
* @param config - Notion config
|
||||
* @returns \{ migrated: boolean; config: IntegrationConfig \}
|
||||
*/
|
||||
export const migrateNotionIntegrationConfig = (
|
||||
config: IntegrationConfig
|
||||
): { migrated: boolean; config: IntegrationConfig } => {
|
||||
// Validate config structure
|
||||
if (!isNotionConfig(config)) {
|
||||
return { migrated: false, config };
|
||||
}
|
||||
|
||||
let anyMigrated = false;
|
||||
|
||||
const newData = config.data.map((item) => {
|
||||
// Cast mapping to the migration type that includes both old and new formats
|
||||
const mapping = item.mapping as NotionMappingEntry[] | undefined;
|
||||
|
||||
// Skip if nothing to migrate
|
||||
if (!needsNotionMigration(mapping)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
anyMigrated = true;
|
||||
|
||||
// Migrate mapping array - check EACH item individually
|
||||
const newMapping = mapping?.map((mapItem) => {
|
||||
// Already has element field - skip this item
|
||||
if ("element" in mapItem) {
|
||||
return mapItem;
|
||||
}
|
||||
|
||||
// Has question field - migrate it
|
||||
if ("question" in mapItem) {
|
||||
const { question, ...rest } = mapItem;
|
||||
return {
|
||||
...rest,
|
||||
element: question,
|
||||
};
|
||||
}
|
||||
|
||||
// Neither element nor question - return as is
|
||||
return mapItem;
|
||||
});
|
||||
|
||||
return {
|
||||
...item,
|
||||
mapping: newMapping,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
migrated: anyMigrated,
|
||||
config: { ...config, data: newData },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate integration config based on type
|
||||
* @param type - Integration type
|
||||
* @param config - Integration config
|
||||
* @returns \{ migrated: boolean; config: IntegrationConfig \}
|
||||
*/
|
||||
export const migrateIntegrationConfig = (
|
||||
type: string,
|
||||
config: IntegrationConfig
|
||||
): { migrated: boolean; config: IntegrationConfig } => {
|
||||
switch (type) {
|
||||
case "googleSheets":
|
||||
case "airtable":
|
||||
case "slack":
|
||||
return migrateSharedIntegrationConfig(config);
|
||||
case "notion":
|
||||
return migrateNotionIntegrationConfig(config);
|
||||
case "n8n":
|
||||
// n8n has no config schema to migrate
|
||||
return { migrated: false, config };
|
||||
default:
|
||||
// Unknown type - return unchanged
|
||||
return { migrated: false, config };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,8 +59,6 @@ checksums:
|
||||
errors/please_fill_out_this_field: 88d4fd502ae8d423277aef723afcd1a7
|
||||
errors/please_rank_all_items_before_submitting: 24fb14a2550bd7ec3e253dda0997cea8
|
||||
errors/please_select_a_date: 1abdc8ffb887dbbdcc0d05486cd84de7
|
||||
errors/please_select_a_rating: e871aa58c243589768f8b266ed6bb0aa
|
||||
errors/please_select_a_value: 0c86021b2b819e94c99ae70bfccfd3f0
|
||||
errors/please_upload_a_file: 4356dfca88553acb377664c923c2d6b7
|
||||
errors/recaptcha_error/message: b3f2c5950cbc0887f391f9e2bccb676e
|
||||
errors/recaptcha_error/title: 8e923ec38a92041569879a39c6467131
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"please_fill_out_this_field": "يرجى ملء هذا الحقل",
|
||||
"please_rank_all_items_before_submitting": "يرجى ترتيب جميع العناصر قبل الإرسال",
|
||||
"please_select_a_date": "يرجى اختيار تاريخ",
|
||||
"please_select_a_rating": "يرجى اختيار تقييم",
|
||||
"please_select_a_value": "يرجى اختيار قيمة",
|
||||
"please_upload_a_file": "يرجى تحميل ملف",
|
||||
"recaptcha_error": {
|
||||
"message": "تعذر إرسال ردك لأنه تم تصنيفه كنشاط آلي. إذا كنت تتنفس، يرجى المحاولة مرة أخرى.",
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"please_fill_out_this_field": "Bitte füllen Sie dieses Feld aus",
|
||||
"please_rank_all_items_before_submitting": "Bitte ordnen Sie alle Elemente vor dem Absenden",
|
||||
"please_select_a_date": "Bitte wählen Sie ein Datum aus",
|
||||
"please_select_a_rating": "Bitte wählen Sie eine Bewertung aus",
|
||||
"please_select_a_value": "Bitte wählen Sie einen Wert aus",
|
||||
"please_upload_a_file": "Bitte laden Sie eine Datei hoch",
|
||||
"recaptcha_error": {
|
||||
"message": "Ihre Antwort konnte nicht übermittelt werden, da sie als automatisierte Aktivität eingestuft wurde. Wenn Sie atmen, versuchen Sie es bitte erneut.",
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"please_fill_out_this_field": "Please fill out this field",
|
||||
"please_rank_all_items_before_submitting": "Please rank all items before submitting",
|
||||
"please_select_a_date": "Please select a date",
|
||||
"please_select_a_rating": "Please select a rating",
|
||||
"please_select_a_value": "Please select a value",
|
||||
"please_upload_a_file": "Please upload a file",
|
||||
"recaptcha_error": {
|
||||
"message": "Your response could not be submitted because it was flagged as automated activity. If you breathe, please try again.",
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"please_fill_out_this_field": "Por favor, complete este campo",
|
||||
"please_rank_all_items_before_submitting": "Por favor, clasifique todos los elementos antes de enviar",
|
||||
"please_select_a_date": "Por favor, seleccione una fecha",
|
||||
"please_select_a_rating": "Por favor selecciona una calificación",
|
||||
"please_select_a_value": "Por favor selecciona un valor",
|
||||
"please_upload_a_file": "Por favor, suba un archivo",
|
||||
"recaptcha_error": {
|
||||
"message": "Su respuesta no pudo ser enviada porque fue marcada como actividad automatizada. Si respira, por favor inténtelo de nuevo.",
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"please_fill_out_this_field": "Veuillez remplir ce champ",
|
||||
"please_rank_all_items_before_submitting": "Veuillez classer tous les éléments avant de soumettre",
|
||||
"please_select_a_date": "Veuillez sélectionner une date",
|
||||
"please_select_a_rating": "Veuillez sélectionner une note",
|
||||
"please_select_a_value": "Veuillez sélectionner une valeur",
|
||||
"please_upload_a_file": "Veuillez télécharger un fichier",
|
||||
"recaptcha_error": {
|
||||
"message": "Votre réponse n'a pas pu être soumise car elle a été signalée comme une activité automatisée. Si vous respirez, veuillez réessayer.",
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"please_fill_out_this_field": "कृपया इस फील्ड को भरें",
|
||||
"please_rank_all_items_before_submitting": "जमा करने से पहले कृपया सभी आइटम्स को रैंक करें",
|
||||
"please_select_a_date": "कृपया एक तारीख चुनें",
|
||||
"please_select_a_rating": "कृपया एक रेटिंग चुनें",
|
||||
"please_select_a_value": "कृपया एक मान चुनें",
|
||||
"please_upload_a_file": "कृपया एक फाइल अपलोड करें",
|
||||
"recaptcha_error": {
|
||||
"message": "आपका प्रतिसाद जमा नहीं किया जा सका क्योंकि इसे स्वचालित गतिविधि के रूप में चिह्नित किया गया था। यदि आप सांस लेते हैं, तो कृपया पुनः प्रयास करें।",
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"please_fill_out_this_field": "Compila questo campo",
|
||||
"please_rank_all_items_before_submitting": "Classifica tutti gli elementi prima di inviare",
|
||||
"please_select_a_date": "Seleziona una data",
|
||||
"please_select_a_rating": "Seleziona una valutazione",
|
||||
"please_select_a_value": "Seleziona un valore",
|
||||
"please_upload_a_file": "Carica un file",
|
||||
"recaptcha_error": {
|
||||
"message": "La tua risposta non può essere inviata perché è stata segnalata come attività automatizzata. Se respiri, riprova.",
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"please_fill_out_this_field": "このフィールドに入力してください",
|
||||
"please_rank_all_items_before_submitting": "送信する前にすべての項目をランク付けしてください",
|
||||
"please_select_a_date": "日付を選択してください",
|
||||
"please_select_a_rating": "評価を選択してください",
|
||||
"please_select_a_value": "値を選択してください",
|
||||
"please_upload_a_file": "ファイルをアップロードしてください",
|
||||
"recaptcha_error": {
|
||||
"message": "自動化された活動としてフラグが立てられたため、回答を送信できませんでした。人間の方は、もう一度お試しください。",
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"please_fill_out_this_field": "Vul dit veld in",
|
||||
"please_rank_all_items_before_submitting": "Rangschik alle items voordat u ze verzendt",
|
||||
"please_select_a_date": "Selecteer een datum",
|
||||
"please_select_a_rating": "Selecteer een beoordeling",
|
||||
"please_select_a_value": "Selecteer een waarde",
|
||||
"please_upload_a_file": "Upload een bestand",
|
||||
"recaptcha_error": {
|
||||
"message": "Uw reactie kan niet worden verzonden omdat deze is gemarkeerd als geautomatiseerde activiteit. Als u ademhaalt, probeer het dan opnieuw.",
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"please_fill_out_this_field": "Por favor, preencha este campo",
|
||||
"please_rank_all_items_before_submitting": "Por favor, classifique todos os itens antes de enviar",
|
||||
"please_select_a_date": "Por favor, selecione uma data",
|
||||
"please_select_a_rating": "Por favor, selecione uma classificação",
|
||||
"please_select_a_value": "Por favor, selecione um valor",
|
||||
"please_upload_a_file": "Por favor, carregue um arquivo",
|
||||
"recaptcha_error": {
|
||||
"message": "Sua resposta não pôde ser enviada porque foi sinalizada como atividade automatizada. Se você respira, por favor tente novamente.",
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"please_fill_out_this_field": "Vă rugăm să completați acest câmp",
|
||||
"please_rank_all_items_before_submitting": "Vă rugăm să clasificați toate elementele înainte de a trimite",
|
||||
"please_select_a_date": "Vă rugăm să selectați o dată",
|
||||
"please_select_a_rating": "Vă rugăm să selectați o evaluare",
|
||||
"please_select_a_value": "Vă rugăm să selectați o valoare",
|
||||
"please_upload_a_file": "Vă rugăm să încărcați un fișier",
|
||||
"recaptcha_error": {
|
||||
"message": "Răspunsul dumneavoastră nu a putut fi trimis deoarece a fost marcat ca activitate automată. Dacă respirați, încercați din nou.",
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"please_fill_out_this_field": "Пожалуйста, заполните это поле",
|
||||
"please_rank_all_items_before_submitting": "Пожалуйста, оцените все элементы перед отправкой",
|
||||
"please_select_a_date": "Пожалуйста, выберите дату",
|
||||
"please_select_a_rating": "Пожалуйста, выберите оценку",
|
||||
"please_select_a_value": "Пожалуйста, выберите значение",
|
||||
"please_upload_a_file": "Пожалуйста, загрузите файл",
|
||||
"recaptcha_error": {
|
||||
"message": "Ваш ответ не может быть отправлен, так как он был помечен как автоматическая активность. Если вы дышите, попробуйте ещё раз.",
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"please_fill_out_this_field": "Iltimos, ushbu maydonni to'ldiring",
|
||||
"please_rank_all_items_before_submitting": "Iltimos, yuborishdan oldin barcha elementlarni baholang",
|
||||
"please_select_a_date": "Iltimos, sanani tanlang",
|
||||
"please_select_a_rating": "Iltimos, reytingni tanlang",
|
||||
"please_select_a_value": "Iltimos, qiymatni tanlang",
|
||||
"please_upload_a_file": "Iltimos, faylni yuklang",
|
||||
"recaptcha_error": {
|
||||
"message": "Sizning javobingiz avtomatlashtirilgan faoliyat sifatida belgilanganligi sababli yuborilmadi. Agar siz nafas olayotgan bo'lsangiz, qayta urinib ko'ring.",
|
||||
|
||||
@@ -64,8 +64,6 @@
|
||||
"please_fill_out_this_field": "请填写此字段",
|
||||
"please_rank_all_items_before_submitting": "请在提交之前对所有项目进行排名",
|
||||
"please_select_a_date": "请选择一个日期",
|
||||
"please_select_a_rating": "请选择一个评分",
|
||||
"please_select_a_value": "请选择一个值",
|
||||
"please_upload_a_file": "请上传一个文件",
|
||||
"recaptcha_error": {
|
||||
"message": "您的响应未能提交,因为它被标记为自动活动。如果您是人类,请重试。",
|
||||
|
||||
@@ -61,7 +61,7 @@ export function CTAElement({
|
||||
subheader={element.subheader ? getLocalizedValue(element.subheader, languageCode) : ""}
|
||||
elementId={element.id}
|
||||
/>
|
||||
{element.buttonExternal && (
|
||||
{element.buttonExternal && element.buttonUrl && (
|
||||
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
|
||||
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-start">
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyNPSElement } from "@formbricks/types/surveys/elements";
|
||||
import { ElementMedia } from "@/components/general/element-media";
|
||||
@@ -19,8 +18,6 @@ interface NPSElementProps {
|
||||
autoFocusEnabled: boolean;
|
||||
currentElementId: string;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
shouldAutoAdvance?: boolean;
|
||||
onAutoSubmit?: (responseData: TResponseData, ttc: TResponseTtc) => void;
|
||||
}
|
||||
|
||||
export function NPSElement({
|
||||
@@ -32,27 +29,17 @@ export function NPSElement({
|
||||
setTtc,
|
||||
currentElementId,
|
||||
dir = "auto",
|
||||
shouldAutoAdvance,
|
||||
onAutoSubmit,
|
||||
}: Readonly<NPSElementProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [hoveredNumber, setHoveredNumber] = useState(-1);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const isMediaAvailable = element.imageUrl || element.videoUrl;
|
||||
const isCurrent = element.id === currentElementId;
|
||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
|
||||
|
||||
const handleClick = (number: number) => {
|
||||
setErrorMessage("");
|
||||
const responseData = { [element.id]: number };
|
||||
onChange(responseData);
|
||||
onChange({ [element.id]: number });
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
|
||||
if (shouldAutoAdvance && onAutoSubmit) {
|
||||
onAutoSubmit(responseData, updatedTtcObj);
|
||||
}
|
||||
};
|
||||
|
||||
const getNPSOptionColor = (idx: number) => {
|
||||
@@ -66,13 +53,6 @@ export function NPSElement({
|
||||
key={element.id}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (element.required && value === undefined) {
|
||||
setErrorMessage(t("errors.please_select_a_value"));
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage("");
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
}}>
|
||||
@@ -86,11 +66,6 @@ export function NPSElement({
|
||||
subheader={element.subheader ? getLocalizedValue(element.subheader, languageCode) : ""}
|
||||
elementId={element.id}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<div className="fb-mt-2 fb-text-sm fb-text-red-500" role="alert" aria-live="assertive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="fb-my-4">
|
||||
<fieldset>
|
||||
<legend className="fb-sr-only">Options</legend>
|
||||
@@ -146,7 +121,7 @@ export function NPSElement({
|
||||
onClick={() => {
|
||||
handleClick(number);
|
||||
}}
|
||||
// Note: We handle required validation manually via onSubmit to show custom error messages
|
||||
required={element.required}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{number}
|
||||
|
||||
@@ -82,9 +82,7 @@ export function OpenTextElement({
|
||||
};
|
||||
|
||||
const validatePhone = (input: HTMLInputElement | HTMLTextAreaElement | null): boolean => {
|
||||
// Match the same pattern as getInputPattern: must start with digit or +, end with digit
|
||||
// Allows digits, +, -, and spaces in between
|
||||
const phoneRegex = /^[0-9+][0-9+\- ]*[0-9]$/;
|
||||
const phoneRegex = /^[+]?[\d\s\-()]{7,}$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
input?.setCustomValidity(t("errors.please_enter_a_valid_phone_number"));
|
||||
input?.reportValidity();
|
||||
@@ -141,6 +139,10 @@ export function OpenTextElement({
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getTextareaTitle = (): string | undefined => {
|
||||
return element.inputType === "phone" ? t("errors.please_enter_a_valid_phone_number") : undefined;
|
||||
};
|
||||
|
||||
const handleInputOnInput = (e: Event) => {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
handleInputChange(input.value);
|
||||
@@ -204,6 +206,7 @@ export function OpenTextElement({
|
||||
value={value}
|
||||
onInput={handleTextareaOnInput}
|
||||
className="fb-border-border placeholder:fb-text-placeholder fb-bg-input-bg fb-text-subheading focus:fb-border-brand fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm"
|
||||
title={getTextareaTitle()}
|
||||
minLength={getInputMinLength()}
|
||||
maxLength={getInputMaxLength()}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import type { JSX } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyRatingElement } from "@formbricks/types/surveys/elements";
|
||||
import { ElementMedia } from "@/components/general/element-media";
|
||||
@@ -32,8 +31,6 @@ interface RatingElementProps {
|
||||
autoFocusEnabled: boolean;
|
||||
currentElementId: string;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
shouldAutoAdvance?: boolean;
|
||||
onAutoSubmit?: (responseData: TResponseData, ttc: TResponseTtc) => void;
|
||||
}
|
||||
|
||||
export function RatingElement({
|
||||
@@ -45,28 +42,18 @@ export function RatingElement({
|
||||
setTtc,
|
||||
currentElementId,
|
||||
dir = "auto",
|
||||
shouldAutoAdvance,
|
||||
onAutoSubmit,
|
||||
}: RatingElementProps) {
|
||||
const { t } = useTranslation();
|
||||
const [hoveredNumber, setHoveredNumber] = useState(0);
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const isMediaAvailable = element.imageUrl || element.videoUrl;
|
||||
const isCurrent = element.id === currentElementId;
|
||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
|
||||
|
||||
const handleSelect = (number: number) => {
|
||||
setErrorMessage("");
|
||||
const responseData = { [element.id]: number };
|
||||
onChange(responseData);
|
||||
onChange({ [element.id]: number });
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
|
||||
// Auto-advance if enabled (single required Rating element in block)
|
||||
if (shouldAutoAdvance && onAutoSubmit) {
|
||||
onAutoSubmit(responseData, updatedTtcObj);
|
||||
}
|
||||
// Note: onSubmit prop is () => {} in multi-element blocks, called by block instead
|
||||
};
|
||||
|
||||
function HiddenRadioInput({ number, id }: { number: number; id?: string }) {
|
||||
@@ -80,7 +67,7 @@ export function RatingElement({
|
||||
onClick={() => {
|
||||
handleSelect(number);
|
||||
}}
|
||||
// Note: We handle required validation manually via handleFormSubmit to show custom error messages
|
||||
required={element.required}
|
||||
checked={value === number}
|
||||
/>
|
||||
);
|
||||
@@ -107,22 +94,15 @@ export function RatingElement({
|
||||
|
||||
const handleFormSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (element.required && value === undefined) {
|
||||
setErrorMessage(t("errors.please_select_a_rating"));
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage("");
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
};
|
||||
|
||||
const handleKeyDown = (number: number) => (e: KeyboardEvent) => {
|
||||
const isActivationKey = e.key === " " || e.key === "Enter";
|
||||
if (isActivationKey) {
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelect(number);
|
||||
document.getElementById(number.toString())?.click();
|
||||
document.getElementById(number.toString())?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -267,11 +247,6 @@ export function RatingElement({
|
||||
subheader={element.subheader ? getLocalizedValue(element.subheader, languageCode) : ""}
|
||||
elementId={element.id}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<div className="fb-mt-2 fb-text-sm fb-text-red-500" role="alert" aria-live="assertive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="fb-mb-4 fb-mt-6 fb-flex fb-items-center fb-justify-center">
|
||||
<fieldset className="fb-w-full">
|
||||
<legend className="fb-sr-only">Choices</legend>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { type TJsFileUploadParams } from "@formbricks/types/js";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import { type TUploadFileConfig } from "@formbricks/types/storage";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import {
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyRankingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { BackButton } from "@/components/buttons/back-button";
|
||||
import { SubmitButton } from "@/components/buttons/submit-button";
|
||||
import { ElementConditional } from "@/components/general/element-conditional";
|
||||
@@ -64,19 +60,6 @@ export function BlockConditional({
|
||||
// Refs to store form elements for each element so we can trigger their validation
|
||||
const elementFormRefs = useRef<Map<string, HTMLFormElement>>(new Map());
|
||||
|
||||
// Ref to collect TTC values synchronously (state updates are async)
|
||||
const ttcCollectorRef = useRef<TResponseTtc>({});
|
||||
|
||||
// Determine if we should auto-advance (single required NPS or Rating element in block)
|
||||
const shouldAutoAdvance = useMemo(() => {
|
||||
if (block.elements.length !== 1) return false;
|
||||
const element = block.elements[0];
|
||||
return (
|
||||
(element.type === TSurveyElementTypeEnum.NPS || element.type === TSurveyElementTypeEnum.Rating) &&
|
||||
element.required
|
||||
);
|
||||
}, [block.elements]);
|
||||
|
||||
// Handle change for an individual element
|
||||
const handleElementChange = (elementId: string, responseData: TResponseData) => {
|
||||
// If user moved to a different element, we should track it
|
||||
@@ -86,11 +69,6 @@ export function BlockConditional({
|
||||
onChange(responseData);
|
||||
};
|
||||
|
||||
// Handler to collect TTC values synchronously (called from element form submissions)
|
||||
const handleTtcCollect = (elementId: string, elementTtc: number) => {
|
||||
ttcCollectorRef.current[elementId] = elementTtc;
|
||||
};
|
||||
|
||||
// Handle skipPrefilled at block level
|
||||
useEffect(() => {
|
||||
if (skipPrefilled && prefilledResponseData) {
|
||||
@@ -122,123 +100,70 @@ export function BlockConditional({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once when block mounts
|
||||
}, []);
|
||||
|
||||
// Validate ranking element
|
||||
const validateRankingElement = (
|
||||
element: TSurveyRankingElement,
|
||||
response: unknown,
|
||||
form: HTMLFormElement
|
||||
): boolean => {
|
||||
const rankingElement = element;
|
||||
const hasIncompleteRanking =
|
||||
(rankingElement.required &&
|
||||
(!Array.isArray(response) || response.length !== rankingElement.choices.length)) ||
|
||||
(!rankingElement.required &&
|
||||
Array.isArray(response) &&
|
||||
response.length > 0 &&
|
||||
response.length < rankingElement.choices.length);
|
||||
|
||||
if (hasIncompleteRanking) {
|
||||
form.requestSubmit();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Check if response is empty
|
||||
const isEmptyResponse = (response: unknown): boolean => {
|
||||
return (
|
||||
response === undefined ||
|
||||
response === null ||
|
||||
response === "" ||
|
||||
(Array.isArray(response) && response.length === 0) ||
|
||||
(typeof response === "object" && !Array.isArray(response) && Object.keys(response).length === 0)
|
||||
);
|
||||
};
|
||||
|
||||
// Validate a single element's form
|
||||
const validateElementForm = (element: TSurveyElement, form: HTMLFormElement): boolean => {
|
||||
// Check HTML5 validity first
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = value[element.id];
|
||||
|
||||
// Custom validation for ranking questions
|
||||
if (element.type === TSurveyElementTypeEnum.Ranking && !validateRankingElement(element, response, form)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For other element types, check if required fields are empty
|
||||
if (element.required && isEmptyResponse(response)) {
|
||||
form.requestSubmit();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Find the first invalid form
|
||||
const findFirstInvalidForm = (): HTMLFormElement | null => {
|
||||
let firstInvalidForm: HTMLFormElement | null = null;
|
||||
|
||||
for (const element of block.elements) {
|
||||
const form = elementFormRefs.current.get(element.id);
|
||||
if (form && !validateElementForm(element, form)) {
|
||||
if (!firstInvalidForm) {
|
||||
firstInvalidForm = form;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return firstInvalidForm;
|
||||
};
|
||||
|
||||
// Collect TTC values from forms
|
||||
const collectTtcValues = (): TResponseTtc => {
|
||||
// Clear the TTC collector before collecting new values
|
||||
ttcCollectorRef.current = {};
|
||||
|
||||
// Call each form's submit method to trigger TTC calculation
|
||||
block.elements.forEach((element) => {
|
||||
const form = elementFormRefs.current.get(element.id);
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
// Collect TTC from the ref (populated synchronously by form submissions)
|
||||
const blockTtc: TResponseTtc = {};
|
||||
block.elements.forEach((element) => {
|
||||
if (ttcCollectorRef.current[element.id] !== undefined) {
|
||||
blockTtc[element.id] = ttcCollectorRef.current[element.id];
|
||||
} else if (ttc[element.id] !== undefined) {
|
||||
blockTtc[element.id] = ttc[element.id];
|
||||
}
|
||||
});
|
||||
|
||||
return blockTtc;
|
||||
};
|
||||
|
||||
// Collect responses for all elements in this block
|
||||
const collectBlockResponses = (): TResponseData => {
|
||||
const blockResponses: TResponseData = {};
|
||||
block.elements.forEach((element) => {
|
||||
if (value[element.id] !== undefined) {
|
||||
blockResponses[element.id] = value[element.id];
|
||||
}
|
||||
});
|
||||
return blockResponses;
|
||||
};
|
||||
|
||||
const handleBlockSubmit = (e?: Event) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Validate all forms and check for custom validation rules
|
||||
const firstInvalidForm = findFirstInvalidForm();
|
||||
let firstInvalidForm: HTMLFormElement | null = null;
|
||||
|
||||
for (const element of block.elements) {
|
||||
const form = elementFormRefs.current.get(element.id);
|
||||
if (form) {
|
||||
// Check HTML5 validity first
|
||||
if (!form.checkValidity()) {
|
||||
if (!firstInvalidForm) {
|
||||
firstInvalidForm = form;
|
||||
}
|
||||
form.reportValidity();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Custom validation for ranking questions
|
||||
if (element.type === TSurveyElementTypeEnum.Ranking) {
|
||||
const response = value[element.id];
|
||||
const rankingElement = element;
|
||||
|
||||
// Check if ranking is incomplete
|
||||
const hasIncompleteRanking =
|
||||
(rankingElement.required &&
|
||||
(!Array.isArray(response) || response.length !== rankingElement.choices.length)) ||
|
||||
(!rankingElement.required &&
|
||||
Array.isArray(response) &&
|
||||
response.length > 0 &&
|
||||
response.length < rankingElement.choices.length);
|
||||
|
||||
if (hasIncompleteRanking) {
|
||||
// Trigger the ranking form's submit to show the error message
|
||||
form.requestSubmit();
|
||||
if (!firstInvalidForm) {
|
||||
firstInvalidForm = form;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// For other element types, check if required fields are empty
|
||||
if (element.required) {
|
||||
const response = value[element.id];
|
||||
const isEmpty =
|
||||
response === undefined ||
|
||||
response === null ||
|
||||
response === "" ||
|
||||
(Array.isArray(response) && response.length === 0) ||
|
||||
(typeof response === "object" && !Array.isArray(response) && Object.keys(response).length === 0);
|
||||
|
||||
if (isEmpty) {
|
||||
form.requestSubmit();
|
||||
if (!firstInvalidForm) {
|
||||
firstInvalidForm = form;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If any form is invalid, scroll to it and stop
|
||||
if (firstInvalidForm) {
|
||||
@@ -246,9 +171,22 @@ export function BlockConditional({
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect TTC and responses, then submit
|
||||
const blockTtc = collectTtcValues();
|
||||
const blockResponses = collectBlockResponses();
|
||||
// All validations passed - collect TTC for all elements in this block
|
||||
const blockTtc: TResponseTtc = {};
|
||||
block.elements.forEach((element) => {
|
||||
if (ttc[element.id] !== undefined) {
|
||||
blockTtc[element.id] = ttc[element.id];
|
||||
}
|
||||
});
|
||||
|
||||
// Collect responses for all elements in this block
|
||||
const blockResponses: TResponseData = {};
|
||||
block.elements.forEach((element) => {
|
||||
if (value[element.id] !== undefined) {
|
||||
blockResponses[element.id] = value[element.id];
|
||||
}
|
||||
});
|
||||
|
||||
onSubmit(blockResponses, blockTtc);
|
||||
};
|
||||
|
||||
@@ -287,43 +225,37 @@ export function BlockConditional({
|
||||
elementFormRefs.current.delete(element.id);
|
||||
}
|
||||
}}
|
||||
onTtcCollect={handleTtcCollect}
|
||||
shouldAutoAdvance={shouldAutoAdvance}
|
||||
onAutoSubmit={onSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Hide navigation buttons when auto-advancing (check the condition for auto-advance above) */}
|
||||
{!shouldAutoAdvance && (
|
||||
<div
|
||||
className={cn(
|
||||
"fb-flex fb-w-full fb-flex-row-reverse fb-justify-between",
|
||||
fullSizeCards ? "fb-sticky fb-bottom-0 fb-bg-white" : ""
|
||||
)}>
|
||||
<div>
|
||||
<SubmitButton
|
||||
buttonLabel={
|
||||
block.buttonLabel ? getLocalizedValue(block.buttonLabel, languageCode) : undefined
|
||||
}
|
||||
isLastQuestion={isLastBlock}
|
||||
onClick={handleBlockSubmit}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
{!isFirstBlock && !isBackButtonHidden && (
|
||||
<BackButton
|
||||
backButtonLabel={
|
||||
block.backButtonLabel ? getLocalizedValue(block.backButtonLabel, languageCode) : undefined
|
||||
}
|
||||
onClick={onBack}
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"fb-flex fb-w-full fb-flex-row-reverse fb-justify-between",
|
||||
fullSizeCards ? "fb-sticky fb-bottom-0 fb-bg-white" : ""
|
||||
)}>
|
||||
<div>
|
||||
<SubmitButton
|
||||
buttonLabel={
|
||||
block.buttonLabel ? getLocalizedValue(block.buttonLabel, languageCode) : undefined
|
||||
}
|
||||
isLastQuestion={isLastBlock}
|
||||
onClick={handleBlockSubmit}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isFirstBlock && !isBackButtonHidden && (
|
||||
<BackButton
|
||||
backButtonLabel={
|
||||
block.backButtonLabel ? getLocalizedValue(block.backButtonLabel, languageCode) : undefined
|
||||
}
|
||||
onClick={onBack}
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
</div>
|
||||
|
||||
@@ -42,9 +42,6 @@ interface ElementConditionalProps {
|
||||
onOpenExternalURL?: (url: string) => void | Promise<void>;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
|
||||
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
|
||||
shouldAutoAdvance?: boolean;
|
||||
onAutoSubmit?: (responseData: TResponseData, ttc: TResponseTtc) => void; // Ideally just calls onSubmit from the block conditional
|
||||
}
|
||||
|
||||
export function ElementConditional({
|
||||
@@ -63,9 +60,6 @@ export function ElementConditional({
|
||||
onOpenExternalURL,
|
||||
dir,
|
||||
formRef,
|
||||
onTtcCollect,
|
||||
shouldAutoAdvance,
|
||||
onAutoSubmit,
|
||||
}: ElementConditionalProps) {
|
||||
// Ref to the container div, used to find and expose the form element inside
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -81,16 +75,6 @@ export function ElementConditional({
|
||||
}
|
||||
}, [formRef]);
|
||||
|
||||
// Wrap setTtc to also call onTtcCollect synchronously
|
||||
// This allows the block to collect TTC values without waiting for async state updates
|
||||
const wrappedSetTtc = (newTtc: TResponseTtc) => {
|
||||
setTtc(newTtc);
|
||||
// Extract this element's TTC and call the collector if provided
|
||||
if (onTtcCollect && newTtc[element.id] !== undefined) {
|
||||
onTtcCollect(element.id, newTtc[element.id]);
|
||||
}
|
||||
};
|
||||
|
||||
const getResponseValueForRankingElement = (value: string[], choices: TSurveyElementChoice[]): string[] => {
|
||||
return value
|
||||
.map((entry) => {
|
||||
@@ -128,7 +112,6 @@ export function ElementConditional({
|
||||
}
|
||||
|
||||
const renderElement = () => {
|
||||
// NOSONAR - This is readable enough and can't be changed
|
||||
switch (element.type) {
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
return (
|
||||
@@ -139,7 +122,7 @@ export function ElementConditional({
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
dir={dir}
|
||||
@@ -154,7 +137,7 @@ export function ElementConditional({
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
dir={dir}
|
||||
@@ -169,7 +152,7 @@ export function ElementConditional({
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
dir={dir}
|
||||
@@ -184,12 +167,10 @@ export function ElementConditional({
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
dir={dir}
|
||||
shouldAutoAdvance={shouldAutoAdvance}
|
||||
onAutoSubmit={onAutoSubmit}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.CTA:
|
||||
@@ -201,7 +182,7 @@ export function ElementConditional({
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
onOpenExternalURL={onOpenExternalURL}
|
||||
@@ -216,12 +197,10 @@ export function ElementConditional({
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
dir={dir}
|
||||
shouldAutoAdvance={shouldAutoAdvance}
|
||||
onAutoSubmit={onAutoSubmit}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
@@ -233,7 +212,7 @@ export function ElementConditional({
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
dir={dir}
|
||||
@@ -248,7 +227,7 @@ export function ElementConditional({
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
/>
|
||||
@@ -262,7 +241,7 @@ export function ElementConditional({
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
dir={dir}
|
||||
@@ -279,7 +258,7 @@ export function ElementConditional({
|
||||
onFileUpload={onFileUpload}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
/>
|
||||
@@ -293,7 +272,7 @@ export function ElementConditional({
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
currentElementId={currentElementId}
|
||||
/>
|
||||
);
|
||||
@@ -305,7 +284,7 @@ export function ElementConditional({
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
currentElementId={currentElementId}
|
||||
/>
|
||||
);
|
||||
@@ -317,7 +296,7 @@ export function ElementConditional({
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
currentElementId={currentElementId}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
dir={dir}
|
||||
@@ -331,7 +310,7 @@ export function ElementConditional({
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
currentElementId={currentElementId}
|
||||
/>
|
||||
@@ -344,7 +323,7 @@ export function ElementConditional({
|
||||
onChange={onChange}
|
||||
languageCode={languageCode}
|
||||
ttc={ttc}
|
||||
setTtc={wrappedSetTtc}
|
||||
setTtc={setTtc}
|
||||
currentElementId={currentElementId}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
dir={dir}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function LanguageSwitch({
|
||||
{showLanguageDropdown ? (
|
||||
<div
|
||||
className={cn(
|
||||
"fb-bg-input-bg fb-text-heading fb-absolute fb-top-10 fb-max-h-64 fb-space-y-2 fb-overflow-auto fb-rounded-md fb-p-2 fb-text-xs fb-border-border fb-border",
|
||||
"fb-bg-brand fb-text-on-brand fb-absolute fb-top-10 fb-space-y-2 fb-rounded-md fb-p-2 fb-text-xs",
|
||||
dir === "rtl" ? "fb-left-8" : "fb-right-8"
|
||||
)}
|
||||
ref={languageDropdownRef}>
|
||||
@@ -109,7 +109,7 @@ export function LanguageSwitch({
|
||||
<button
|
||||
key={surveyLanguage.language.id}
|
||||
type="button"
|
||||
className="fb-block fb-w-full fb-p-1.5 fb-rounded-md fb-text-left hover:fb-bg-brand hover:fb-text-on-brand fb-max-w-48 fb-truncate"
|
||||
className="fb-block fb-w-full fb-p-1.5 fb-text-left hover:fb-opacity-80"
|
||||
onClick={() => {
|
||||
changeLanguage(surveyLanguage.language.code);
|
||||
}}>
|
||||
|
||||
@@ -9,8 +9,6 @@ import type {
|
||||
TResponseVariables,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||
import { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { BlockConditional } from "@/components/general/block-conditional";
|
||||
import { EndingCard } from "@/components/general/ending-card";
|
||||
import { ErrorComponent } from "@/components/general/error-component";
|
||||
@@ -352,21 +350,20 @@ export function Survey({
|
||||
};
|
||||
|
||||
const makeQuestionsRequired = (requiredQuestionIds: string[]): void => {
|
||||
const updateElementIfRequired = (element: TSurveyElement) => {
|
||||
if (requiredQuestionIds.includes(element.id)) {
|
||||
return { ...element, required: true };
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
const updateBlockElements = (block: TSurveyBlock) => ({
|
||||
...block,
|
||||
elements: block.elements.map(updateElementIfRequired),
|
||||
});
|
||||
|
||||
setlocalSurvey((prevSurvey) => ({
|
||||
...prevSurvey,
|
||||
blocks: prevSurvey.blocks.map(updateBlockElements),
|
||||
blocks: prevSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((element) => {
|
||||
if (requiredQuestionIds.includes(element.id)) {
|
||||
return {
|
||||
...element,
|
||||
required: true,
|
||||
};
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -374,24 +371,20 @@ export function Survey({
|
||||
const questionsToRevert = questionRequiredByMap.current[questionId] || [];
|
||||
|
||||
if (questionsToRevert.length > 0) {
|
||||
const revertElementIfNeeded = (element: TSurveyElement) => {
|
||||
if (questionsToRevert.includes(element.id)) {
|
||||
return {
|
||||
...element,
|
||||
required: originalQuestionRequiredStates[element.id] ?? element.required,
|
||||
};
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
const updateBlockElements = (block: TSurveyBlock) => ({
|
||||
...block,
|
||||
elements: block.elements.map(revertElementIfNeeded),
|
||||
});
|
||||
|
||||
setlocalSurvey((prevSurvey) => ({
|
||||
...prevSurvey,
|
||||
blocks: prevSurvey.blocks.map(updateBlockElements),
|
||||
blocks: prevSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((element) => {
|
||||
if (questionsToRevert.includes(element.id)) {
|
||||
return {
|
||||
...element,
|
||||
required: originalQuestionRequiredStates[element.id] ?? element.required,
|
||||
};
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// remove the question from the map
|
||||
@@ -435,85 +428,54 @@ export function Survey({
|
||||
throw new Error("Block not found");
|
||||
}
|
||||
|
||||
const localResponseData = { ...responseData, ...data };
|
||||
let firstJumpTarget: string | undefined;
|
||||
const allRequiredQuestionIds: string[] = [];
|
||||
|
||||
let calculationResults = { ...currentVariables };
|
||||
|
||||
// Process a single logic rule
|
||||
const processLogicRule = (
|
||||
logic: TSurveyBlockLogic,
|
||||
currentJumpTarget: string | undefined,
|
||||
currentRequiredIds: string[]
|
||||
): { jumpTarget: string | undefined; requiredIds: string[]; updatedCalculations: TResponseVariables } => {
|
||||
const isLogicMet = evaluateLogic(
|
||||
localSurvey,
|
||||
localResponseData,
|
||||
calculationResults,
|
||||
logic.conditions,
|
||||
selectedLanguage
|
||||
);
|
||||
|
||||
if (!isLogicMet) {
|
||||
return {
|
||||
jumpTarget: currentJumpTarget,
|
||||
requiredIds: currentRequiredIds,
|
||||
updatedCalculations: calculationResults,
|
||||
};
|
||||
}
|
||||
|
||||
const { jumpTarget, requiredQuestionIds, calculations } = performActions(
|
||||
localSurvey,
|
||||
logic.actions,
|
||||
localResponseData,
|
||||
calculationResults
|
||||
);
|
||||
|
||||
const newJumpTarget = jumpTarget && !currentJumpTarget ? jumpTarget : currentJumpTarget;
|
||||
const newRequiredIds = [...currentRequiredIds, ...requiredQuestionIds];
|
||||
const updatedCalculations = { ...calculationResults, ...calculations };
|
||||
|
||||
return {
|
||||
jumpTarget: newJumpTarget,
|
||||
requiredIds: newRequiredIds,
|
||||
updatedCalculations,
|
||||
};
|
||||
};
|
||||
const localResponseData = { ...responseData, ...data };
|
||||
|
||||
// Evaluate block-level logic
|
||||
const evaluateBlockLogic = () => {
|
||||
let firstJumpTarget: string | undefined;
|
||||
const allRequiredQuestionIds: string[] = [];
|
||||
if (currentBlock.logic && currentBlock.logic.length > 0) {
|
||||
for (const logic of currentBlock.logic) {
|
||||
if (
|
||||
evaluateLogic(
|
||||
localSurvey,
|
||||
localResponseData,
|
||||
calculationResults,
|
||||
logic.conditions,
|
||||
selectedLanguage
|
||||
)
|
||||
) {
|
||||
const { jumpTarget, requiredQuestionIds, calculations } = performActions(
|
||||
localSurvey,
|
||||
logic.actions,
|
||||
localResponseData,
|
||||
calculationResults
|
||||
);
|
||||
|
||||
if (currentBlock.logic && currentBlock.logic.length > 0) {
|
||||
for (const logic of currentBlock.logic) {
|
||||
const result = processLogicRule(logic, firstJumpTarget, allRequiredQuestionIds);
|
||||
firstJumpTarget = result.jumpTarget;
|
||||
allRequiredQuestionIds.length = 0;
|
||||
allRequiredQuestionIds.push(...result.requiredIds);
|
||||
calculationResults = result.updatedCalculations;
|
||||
if (jumpTarget && !firstJumpTarget) {
|
||||
firstJumpTarget = jumpTarget; // This is already a block ID from performActions
|
||||
}
|
||||
|
||||
allRequiredQuestionIds.push(...requiredQuestionIds);
|
||||
calculationResults = { ...calculationResults, ...calculations };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use logicFallback if no jump target was set
|
||||
if (!firstJumpTarget && currentBlock.logicFallback) {
|
||||
firstJumpTarget = currentBlock.logicFallback;
|
||||
// Use logicFallback if no jump target was set (logicFallback is at block level)
|
||||
if (!firstJumpTarget && currentBlock.logicFallback) {
|
||||
firstJumpTarget = currentBlock.logicFallback;
|
||||
}
|
||||
|
||||
if (allRequiredQuestionIds.length > 0) {
|
||||
// Track which questions are being made required by this block
|
||||
if (currentBlock.elements[0]) {
|
||||
questionRequiredByMap.current[currentBlock.elements[0].id] = allRequiredQuestionIds;
|
||||
}
|
||||
|
||||
return { firstJumpTarget, allRequiredQuestionIds };
|
||||
};
|
||||
|
||||
const { firstJumpTarget, allRequiredQuestionIds } = evaluateBlockLogic();
|
||||
|
||||
// Handle required questions
|
||||
const handleRequiredQuestions = (requiredIds: string[]) => {
|
||||
if (requiredIds.length > 0) {
|
||||
if (currentBlock.elements[0]) {
|
||||
questionRequiredByMap.current[currentBlock.elements[0].id] = requiredIds;
|
||||
}
|
||||
makeQuestionsRequired(requiredIds);
|
||||
}
|
||||
};
|
||||
|
||||
handleRequiredQuestions(allRequiredQuestionIds);
|
||||
makeQuestionsRequired(allRequiredQuestionIds);
|
||||
}
|
||||
|
||||
// Return the jump target (which is a block ID) or the next block in sequence
|
||||
const nextBlockId = firstJumpTarget || localSurvey.blocks[currentBlockIndex + 1]?.id;
|
||||
|
||||
@@ -61,20 +61,7 @@ export type TSurveyBlockLogicActionObjective = "calculate" | "requireAnswer" | "
|
||||
export const ZActionRequireAnswer = z.object({
|
||||
id: ZId,
|
||||
objective: z.literal("requireAnswer"),
|
||||
target: z
|
||||
.string()
|
||||
.min(1, "Conditional Logic: Target question id cannot be empty")
|
||||
.superRefine((id, ctx) => {
|
||||
const idParsed = ZSurveyElementId.safeParse(id);
|
||||
if (!idParsed.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
// This is not possible from the UI hence we can use the term "element" instead of "question"
|
||||
message: "Conditional Logic: Target element id is not a valid element id",
|
||||
path: ["target"],
|
||||
});
|
||||
}
|
||||
}),
|
||||
target: ZSurveyElementId,
|
||||
});
|
||||
|
||||
export type TActionRequireAnswer = z.infer<typeof ZActionRequireAnswer>;
|
||||
@@ -84,18 +71,7 @@ export type TActionRequireAnswer = z.infer<typeof ZActionRequireAnswer>;
|
||||
export const ZActionJumpToBlock = z.object({
|
||||
id: ZId,
|
||||
objective: z.literal("jumpToBlock"),
|
||||
target: z
|
||||
.string()
|
||||
.min(1, "Conditional Logic: Target block id cannot be empty")
|
||||
.superRefine((id, ctx) => {
|
||||
const idParsed = ZSurveyBlockId.safeParse(id);
|
||||
if (!idParsed.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Conditional Logic: Target block id is not a valid block id",
|
||||
});
|
||||
}
|
||||
}),
|
||||
target: ZSurveyBlockId, // Must be a valid CUID
|
||||
});
|
||||
|
||||
export type TActionJumpToBlock = z.infer<typeof ZActionJumpToBlock>;
|
||||
|
||||
@@ -61,7 +61,7 @@ export const validateElementLabels = (
|
||||
) {
|
||||
return {
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `The ${field} in question ${String(elementIndex + 1)} of block ${String(blockIndex + 1)} is not present for the following languages: ${language.language.code}`,
|
||||
message: `The ${field} in element ${String(elementIndex + 1)} of block ${String(blockIndex + 1)} is not present for the following languages: ${language.language.code}`,
|
||||
path: ["blocks", blockIndex, "elements", elementIndex, field],
|
||||
};
|
||||
}
|
||||
@@ -75,8 +75,8 @@ export const validateElementLabels = (
|
||||
const messageSuffix = isDefaultOnly ? " is missing" : " is missing for the following languages: ";
|
||||
|
||||
const message = isDefaultOnly
|
||||
? `${messagePrefix}${messageField} in question ${String(elementIndex + 1)} of block ${String(blockIndex + 1)}${messageSuffix}`
|
||||
: `${messagePrefix}${messageField} in question ${String(elementIndex + 1)} of block ${String(blockIndex + 1)}${messageSuffix} -fLang- ${invalidLanguageCodes.join()}`;
|
||||
? `${messagePrefix}${messageField} in element ${String(elementIndex + 1)} of block ${String(blockIndex + 1)}${messageSuffix}`
|
||||
: `${messagePrefix}${messageField} in element ${String(elementIndex + 1)} of block ${String(blockIndex + 1)}${messageSuffix} -fLang- ${invalidLanguageCodes.join()}`;
|
||||
|
||||
if (invalidLanguageCodes.length) {
|
||||
return {
|
||||
|
||||
@@ -1307,7 +1307,18 @@ export const ZSurvey = z
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Build map of all elements across all blocks
|
||||
// 2. Validate block names are unique (for editor usability)
|
||||
const blockNames = blocks.map((b) => b.name);
|
||||
const uniqueBlockNames = new Set(blockNames);
|
||||
if (uniqueBlockNames.size !== blockNames.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Block names must be unique",
|
||||
path: ["blocks", blockNames.findIndex((name, index) => blockNames.indexOf(name) !== index), "name"],
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Build map of all elements across all blocks
|
||||
const allElements = new Map<string, { block: number; element: number; data: TSurveyElement }>();
|
||||
blocks.forEach((block, blockIdx) => {
|
||||
block.elements.forEach((element, elemIdx) => {
|
||||
@@ -1355,10 +1366,7 @@ export const ZSurvey = z
|
||||
}
|
||||
}
|
||||
|
||||
//only validate back button label for blocks other than the first one and if back button is not hidden
|
||||
if (
|
||||
!isBackButtonHidden &&
|
||||
blockIndex > 0 &&
|
||||
block.backButtonLabel?.[defaultLanguageCode] &&
|
||||
block.backButtonLabel[defaultLanguageCode].trim() !== ""
|
||||
) {
|
||||
@@ -1609,7 +1617,7 @@ export const ZSurvey = z
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Question ${String(elementIndex + 1)} in block ${String(blockIndex + 1)} has duplicate row labels ${isDefaultOnly ? "" : "for the following languages:"}`,
|
||||
message: `Element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)} has duplicate row labels ${isDefaultOnly ? "" : "for the following languages:"}`,
|
||||
path: ["blocks", blockIndex, "elements", elementIndex, "rows"],
|
||||
params: isDefaultOnly ? undefined : { invalidLanguageCodes },
|
||||
});
|
||||
@@ -1627,7 +1635,7 @@ export const ZSurvey = z
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Question ${String(elementIndex + 1)} in block ${String(blockIndex + 1)} has duplicate column labels ${isDefaultOnly ? "" : "for the following languages:"}`,
|
||||
message: `Element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)} has duplicate column labels ${isDefaultOnly ? "" : "for the following languages:"}`,
|
||||
path: ["blocks", blockIndex, "elements", elementIndex, "columns"],
|
||||
params: isDefaultOnly ? undefined : { invalidLanguageCodes },
|
||||
});
|
||||
@@ -1638,7 +1646,7 @@ export const ZSurvey = z
|
||||
if (element.allowedFileExtensions && element.allowedFileExtensions.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Question ${String(elementIndex + 1)} in block ${String(blockIndex + 1)} must have atleast one allowed file extension`,
|
||||
message: `Element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)} must have atleast one allowed file extension`,
|
||||
path: ["blocks", blockIndex, "elements", elementIndex, "allowedFileExtensions"],
|
||||
});
|
||||
}
|
||||
@@ -1650,7 +1658,7 @@ export const ZSurvey = z
|
||||
if (!hostnameRegex.test(element.calHost)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Question ${String(elementIndex + 1)} in block ${String(blockIndex + 1)} must have a valid host name`,
|
||||
message: `Element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)} must have a valid host name`,
|
||||
path: ["blocks", blockIndex, "elements", elementIndex, "calHost"],
|
||||
});
|
||||
}
|
||||
@@ -1670,7 +1678,7 @@ export const ZSurvey = z
|
||||
if (fields.every((field) => !field.show)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `At least one field must be shown in the Contact Info question ${String(elementIndex + 1)} in block ${String(blockIndex + 1)}`,
|
||||
message: `At least one field must be shown in the Contact Info element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)}`,
|
||||
path: ["blocks", blockIndex, "elements", elementIndex],
|
||||
});
|
||||
}
|
||||
@@ -1712,7 +1720,7 @@ export const ZSurvey = z
|
||||
if (fields.every((field) => !field.show)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `At least one field must be shown in the Address question ${String(elementIndex + 1)} in block ${String(blockIndex + 1)}`,
|
||||
message: `At least one field must be shown in the Address element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)}`,
|
||||
path: ["blocks", blockIndex, "elements", elementIndex],
|
||||
});
|
||||
}
|
||||
@@ -3106,7 +3114,7 @@ const validateBlockConditions = (
|
||||
if (!elementInfo) {
|
||||
issues.push({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Conditional Logic: Element Id ${elementId} does not exist in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`,
|
||||
message: `Conditional Logic: Element ID ${elementId} does not exist in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`,
|
||||
path: ["blocks", blockIndex, "logic", logicIndex, "conditions"],
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -313,7 +313,7 @@ export const validateId = (
|
||||
const combinedIds = [...existingElementIds, ...existingHiddenFieldIds, ...existingEndingCardIds];
|
||||
|
||||
if (combinedIds.findIndex((id) => id.toLowerCase() === field.toLowerCase()) !== -1) {
|
||||
return `${type} ID already exists in questions or hidden fields`;
|
||||
return `${type} ID already exists in questions, hidden fields, or elements.`;
|
||||
}
|
||||
|
||||
if (FORBIDDEN_IDS.includes(field)) {
|
||||
|
||||
Reference in New Issue
Block a user