Compare commits

..

121 Commits

Author SHA1 Message Date
pandeymangg e1e5ce6270 fixes sonarqube issues 2025-11-27 18:06:00 +05:30
pandeymangg 86cc8fb8ff chore: merge with main 2025-11-27 16:30:35 +05:30
pandeymangg ee56cc10e7 fixes unit tests 2025-11-27 13:22:29 +05:30
pandeymangg 7c47299775 fixes sonarqube issues 2025-11-27 13:11:39 +05:30
pandeymangg 92f4f04f7c fixes another e2e test 2025-11-27 13:07:01 +05:30
pandeymangg e072a0e889 Merge branch 'fix/mqp-code-cleanup' into fix/mqp-e2e-fix 2025-11-27 11:56:44 +05:30
pandeymangg 4723a428e7 fixes coderabbit feedback 2025-11-27 11:55:33 +05:30
pandeymangg d7692a1b76 fix: e2e tests 2025-11-26 23:02:52 +05:30
pandeymangg a08f2db40c fix: e2e tests 2025-11-26 22:07:10 +05:30
pandeymangg 48eb4fe705 fix: e2e tests 2025-11-26 21:57:32 +05:30
pandeymangg 26a2d50d45 Merge branch 'epic/survey-mqp' into fix/mqp-code-cleanup 2025-11-26 14:34:55 +05:30
Anshuman Pandey da5d9e27e1 fix: backwards compatibility for v1 management apis (#6882) 2025-11-26 14:33:56 +05:30
pandeymangg 8e2934d7bb chore: merge 2025-11-26 14:09:10 +05:30
pandeymangg 799b86801d code coverage 2025-11-26 14:06:32 +05:30
pandeymangg 9d77b808d0 coderabbit feedback 2025-11-26 13:39:15 +05:30
pandeymangg 007d996870 integrations code cleanup 2025-11-26 12:32:27 +05:30
pandeymangg 9f59d7a967 code cleanup 2025-11-25 22:31:44 +05:30
pandeymangg b9c647ef62 Merge branch 'epic/survey-mqp' into fix/questions-backwards-compat 2025-11-25 13:53:33 +05:30
Anshuman Pandey 7770d43f9a fix: cta logic and template fixes (#6849) 2025-11-25 09:22:20 +01:00
pandeymangg 615aa6aaad Merge branch 'fix/cta-logic-templates' into fix/questions-backwards-compat 2025-11-25 13:32:54 +05:30
pandeymangg f57ca755f4 backwards compatibility for management v1 apis 2025-11-25 13:32:08 +05:30
pandeymangg fd37b978c0 tests and templates fixes 2025-11-25 10:41:20 +05:30
pandeymangg d40ce9ce84 cta element required changes 2025-11-24 22:51:43 +05:30
Anshuman Pandey 21c63bc400 feat: mqp data and question literal migration (#6848) 2025-11-24 13:31:51 +01:00
pandeymangg 77722aa638 test fix 2025-11-24 17:19:46 +05:30
pandeymangg 2a9897370e build fixes 2025-11-24 17:15:45 +05:30
pandeymangg 5e85347bf5 Merge branch 'fix/blocks-migration' into fix/cta-logic-templates 2025-11-24 16:43:04 +05:30
pandeymangg e4a9d28b4b migration script changes 2025-11-24 12:12:39 +05:30
pandeymangg b79703f87e Merge branch 'fix/blocks-migration' into fix/cta-logic-templates 2025-11-21 16:36:05 +05:30
pandeymangg 567cc4b893 chore: merge with epic 2025-11-21 16:34:53 +05:30
Anshuman Pandey d9b37496fc feat: blocks UI part 2 (#6832) 2025-11-21 16:29:57 +05:30
pandeymangg 87a06c846a coverage 2025-11-21 16:24:13 +05:30
pandeymangg c5e02a597d fixes unit tests 2025-11-21 16:08:48 +05:30
pandeymangg 1fec0ca7a6 fixes sonarqube issues 2025-11-21 16:00:16 +05:30
pandeymangg ae165eac87 fixes sonarqube medium issue 2025-11-21 15:48:08 +05:30
pandeymangg 7de5fdc383 fixes sonarqube issue 2025-11-21 15:36:31 +05:30
pandeymangg 3e61d31041 fix 2025-11-21 15:34:26 +05:30
pandeymangg d7e537f699 fixes UI issues 2025-11-21 15:26:55 +05:30
pandeymangg 1e6c7609b6 fixes unit tests 2025-11-21 14:35:48 +05:30
pandeymangg 59438d9afe colocates migration types and utils 2025-11-21 14:21:33 +05:30
pandeymangg e234ed78cf Merge branch 'feat/blocks-ui-2' into fix/blocks-migration 2025-11-21 13:59:50 +05:30
pandeymangg 74b168d727 Merge branch 'fix/blocks-migration' of https://github.com/formbricks/formbricks into fix/blocks-migration 2025-11-21 13:53:32 +05:30
pandeymangg 2ed2da61cd migration feedback 2025-11-21 13:53:13 +05:30
pandeymangg 729f269d4e fixes validation issue 2025-11-21 12:50:56 +05:30
pandeymangg 41776d0001 fixes first block delete issue 2025-11-21 12:21:28 +05:30
pandeymangg a2a6870a21 fixes coderabbit feedback 2025-11-21 11:41:55 +05:30
pandeymangg 3ab62968e5 chore: merge with epic 2025-11-20 22:09:07 +05:30
Anshuman Pandey d4f7f0f35d feat: blocks UI Part 1 (#6816) 2025-11-20 16:32:25 +01:00
pandeymangg a10cd0cb47 Merge branch 'fix/blocks-migration' into fix/cta-logic-templates 2025-11-20 17:10:52 +05:30
pandeymangg 45100673f1 chore: merge with blocks ui p2 2025-11-20 17:09:38 +05:30
pandeymangg 35f53769a5 Merge branch 'feat/blocks-ui' into feat/blocks-ui-2 2025-11-20 17:05:20 +05:30
pandeymangg 22ad78a187 fixes feedback 2025-11-20 17:02:12 +05:30
pandeymangg 67076c4b4c fixes surveys package build 2025-11-20 13:48:44 +05:30
pandeymangg 74bfeb132e Merge branch 'fix/blocks-migration' into fix/cta-logic-templates 2025-11-20 13:43:15 +05:30
pandeymangg 562b4047ae updates migration script and types to change the question literal to element 2025-11-20 13:17:12 +05:30
pandeymangg 4791018546 cta logic and template changes 2025-11-19 17:17:17 +05:30
pandeymangg be1e546729 migration script cleanup 2025-11-19 14:13:16 +05:30
pandeymangg 5bad0da477 adds migration 2025-11-19 11:38:54 +05:30
pandeymangg 9c776c5e4e feedback 2025-11-19 09:59:53 +05:30
Johannes c50b46f715 clean up block & question toggles for clarity 2025-11-18 12:16:16 +01:00
pandeymangg ce0a0573be fixes logic error about previous blocks 2025-11-18 14:34:37 +05:30
pandeymangg 3e27143ab1 cta question changes 2025-11-17 17:10:04 +05:30
pandeymangg 018e2883ff logic editor UI fixes 2025-11-17 16:41:15 +05:30
pandeymangg 85fb7ca956 implements logic editor UI, move question to blocks, etc 2025-11-17 15:15:53 +05:30
pandeymangg 2258699156 moves button label settings to the block level 2025-11-14 17:48:12 +05:30
pandeymangg b1a7b929bd removes cta and cal.com element restrictions 2025-11-14 15:17:58 +05:30
pandeymangg fded9a3bad scrollable container 2025-11-14 13:07:07 +05:30
pandeymangg 4d84468269 fixes some validations 2025-11-14 11:29:44 +05:30
pandeymangg e6e010e801 fixes welcome card and ending card active in preview survey 2025-11-13 18:56:27 +05:30
pandeymangg 8ced882406 fixes preview active block 2025-11-13 18:43:47 +05:30
pandeymangg f7d462cc7f surveys package UI changes for supporting blocks 2025-11-13 17:34:37 +05:30
pandeymangg f3d679d087 Merge branch 'epic/survey-mqp' into feat/survey-editor-blocks-ui 2025-11-12 22:37:12 +05:30
pandeymangg c79a600efc initial UI changes for the PoC 2025-11-12 22:23:47 +05:30
Anshuman Pandey 7a8da3b84b feat: migrate all templates from questions to blocks structure (#6798) 2025-11-12 16:15:51 +05:30
Matti Nannt 4b2d48397d chore: fix tests 2025-11-12 11:16:31 +01:00
Matti Nannt 3ea81dc7c1 chore: remove unused templates functions for questions, fix linting issues 2025-11-12 10:54:59 +01:00
pandeymangg d9b6b550a9 Merge branch 'epic/survey-mqp' into feat/migrate-templates-to-blocks 2025-11-12 10:32:45 +05:30
Anshuman Pandey 56a6ba08ba fix: moves the integrations code to blocks schema (#6800) 2025-11-11 23:23:07 +05:30
pandeymangg 1ba55ff66c fixes tests 2025-11-11 22:22:53 +05:30
pandeymangg 0cf621d76c chore: merge with the epic branch 2025-11-11 22:12:41 +05:30
pandeymangg 3dc615fdc0 chore: merge with the epic branch 2025-11-11 22:08:12 +05:30
Anshuman Pandey 7157b17901 feat: survey summary blocks (#6795) 2025-11-11 22:06:43 +05:30
pandeymangg 82c26941e4 fixes coderabbit feedback 2025-11-11 22:05:45 +05:30
pandeymangg 591d5fa3d4 fixed tests for the xm templates 2025-11-11 16:47:42 +05:30
pandeymangg 211bca1bd8 moves the xm-templates to blocks 2025-11-11 16:13:46 +05:30
pandeymangg 5a20839c5b fixes template logic 2025-11-11 14:35:35 +05:30
pandeymangg 85743bd3d0 fixes feedback 2025-11-11 11:58:59 +05:30
pandeymangg 335ec02361 moves the integrations code over to the blocks schema 2025-11-11 11:16:08 +05:30
Matti Nannt 7918523957 feat: migrate all templates from questions to blocks structure 2025-11-10 16:44:55 +01:00
pandeymangg 3b5fe4cb94 some build fixes 2025-11-10 16:48:32 +05:30
pandeymangg 6bbd5ec7ef Merge branch 'epic/survey-mqp' into feat/survey-summary-blocks 2025-11-10 16:18:43 +05:30
pandeymangg c9542dcf79 moving survey summary, responses, follow ups to blocks 2025-11-10 16:18:00 +05:30
Anshuman Pandey 4277a9dc34 feat: Moving surveys package logic to blocks (#6785) 2025-11-10 09:47:46 +05:30
pandeymangg b1da63e47d fixes description issue 2025-11-07 14:22:13 +05:30
pandeymangg 8c05154a86 fixes feedback 2025-11-07 12:30:25 +05:30
pandeymangg 45122de652 surveys package changes for supporting blocks 2025-11-06 19:10:58 +05:30
Anshuman Pandey 2180bf98ba feat: refactor survey editor logic to use blocks model (#6778) 2025-11-06 15:45:15 +05:30
pandeymangg 2d4a94721b removes log 2025-11-06 15:11:16 +05:30
pandeymangg b2b97c8bed fixes feedback comments 2025-11-06 12:02:25 +05:30
pandeymangg f349f7199d fixes unit tests 2025-11-05 11:57:35 +05:30
pandeymangg e7d8803a13 fixes coderabbit feedback 2025-11-05 11:04:17 +05:30
pandeymangg 53a9b218bc fixes coderabbit feedback 2025-11-05 10:26:20 +05:30
pandeymangg c618e7d473 survey mqp survey editor logic 2025-11-04 22:31:35 +05:30
Anshuman Pandey 3d0f703ae1 feat(blocks): add editor utilities, validation, and unit tests for bl… (#6768) 2025-11-03 20:40:52 +05:30
pandeymangg 33eadaaa7b feedback 2025-11-03 16:37:24 +05:30
pandeymangg 452617529c updates error message 2025-11-03 14:11:53 +05:30
pandeymangg 5951eea618 feedback 2025-11-03 13:10:30 +05:30
pandeymangg e314feb416 fix 2025-11-03 11:18:11 +05:30
pandeymangg 0910b0f1a7 fix: sonar issues 2025-11-03 10:59:58 +05:30
pandeymangg 10ba42eb31 fix: code duplication 2025-11-03 10:28:02 +05:30
pandeymangg 04f1e17e23 fix: tests 2025-11-03 10:13:19 +05:30
pandeymangg 4642cc60c9 fix: coderabbit feedback 2025-11-02 17:59:16 +05:30
pandeymangg 49fa5c587c feat(blocks): add editor utilities, validation, and unit tests for blocks support 2025-10-31 17:32:47 +05:30
Anshuman Pandey 4f9b48b5e5 feat: add blocks model to support multi-question pages (schema only) (#6754) 2025-10-31 11:52:35 +05:30
pandeymangg 80789327d0 fix: feedback 2025-10-31 11:32:01 +05:30
pandeymangg 38108a32d1 fix: feedback 2025-10-31 09:18:18 +05:30
pandeymangg ce4b64da0e fix(validation): fix cyclic logic detection and add choice ID validation in block logic 2025-10-30 15:33:46 +05:30
pandeymangg 9790b071d7 fix(validation): correct operator names to match enum definition
Update comparison operators from 'lessThan/lessEqual/greaterThan/greaterEqual'
to 'isLessThan/isLessThanOrEqual/isGreaterThan/isGreaterThanOrEqual' to match
ZSurveyLogicConditionsOperator enum in OpenText number, NPS, and Rating validation.
2025-10-30 15:15:20 +05:30
pandeymangg 1f5ba0e60e fix: sonar duplicate import issue 2025-10-30 14:07:05 +05:30
pandeymangg b502bbc91e refactor(types): extract i18n and logic types to resolve circular dependencies
Moves TI18nString to packages/types/i18n.ts and all logic types to packages/types/surveys/logic.ts, updating imports across codebase.
2025-10-30 13:58:00 +05:30
pandeymangg 6772ac7c20 feat: add blocks model to support multi-question pages (schema only) 2025-10-30 00:00:53 +05:30
100 changed files with 2313 additions and 7038 deletions
@@ -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);
};
+9 -33
View File
@@ -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";
@@ -51,22 +50,6 @@ export const POST = async (request: Request) => {
throw new ResourceNotFoundError("Organization", "Organization not found");
}
// Fetch survey for webhook payload
const survey = await getSurvey(surveyId);
if (!survey) {
logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
return responses.notFoundResponse("Survey", surveyId, true);
}
if (survey.environmentId !== environmentId) {
logger.error(
{ url: request.url, surveyId, environmentId, surveyEnvironmentId: survey.environmentId },
`Survey ${surveyId} does not belong to environment ${environmentId}`
);
return responses.badRequestResponse("Survey not found in this environment");
}
// Fetch webhooks
const getWebhooksForPipeline = async (environmentId: string, event: PipelineTriggers, surveyId: string) => {
const webhooks = await prisma.webhook.findMany({
@@ -97,16 +80,7 @@ export const POST = async (request: Request) => {
body: JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
survey: {
title: survey.name,
type: survey.type,
status: survey.status,
createdAt: survey.createdAt,
updatedAt: survey.updatedAt,
},
},
data: response,
}),
}).catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
@@ -114,12 +88,18 @@ export const POST = async (request: Request) => {
);
if (event === "responseFinished") {
// Fetch integrations and responseCount in parallel
const [integrations, responseCount] = await Promise.all([
// Fetch integrations, survey, and responseCount in parallel
const [integrations, survey, responseCount] = await Promise.all([
getIntegrations(environmentId),
getSurvey(surveyId),
getResponseCountBySurveyId(surveyId),
]);
if (!survey) {
logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
return new Response("Survey not found", { status: 404 });
}
if (integrations.length > 0) {
await handleIntegrations(integrations, inputValidation.data, survey);
}
@@ -246,10 +226,6 @@ export const POST = async (request: Request) => {
}
});
}
if (event === "responseCreated") {
// Send telemetry events
await sendTelemetryEvents();
}
return Response.json({ data: {} });
};
+35 -54
View File
@@ -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);
}
+4 -6
View File
@@ -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 };
+3 -6
View File
@@ -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,
}),
],
+1 -2
View File
@@ -17,8 +17,7 @@
"zh-Hans-CN",
"zh-Hant-TW",
"nl-NL",
"es-ES",
"sv-SE"
"es-ES"
]
},
"version": 1.8
+4 -7
View File
@@ -395,7 +395,6 @@ checksums:
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
common/updated_at: 8fdb85248e591254973403755dcc3724
common/upload: 4a6c84aa16db0f4e5697f49b45257bc7
common/upload_failed: d4dd7b6ee4c1572e4136659f74d9632b
common/upload_input_description: 64f59bc339568d52b8464b82546b70ea
common/url: ca97457614226960d41dd18c3c29c86b
common/user: 61073457a5c3901084b557d065f876be
@@ -1171,7 +1170,8 @@ checksums:
environments/surveys/edit/automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds: 1be3819ffa1db67385357ae933d69a7b
environments/surveys/edit/automatically_mark_the_survey_as_complete_after: c6ede2a5515a4ca72b36aec2583f43aa
environments/surveys/edit/back_button_label: 25af945e77336724b5276de291cc92d9
environments/surveys/edit/background_styling: eb4a06cf54a7271b493fab625d930570
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
@@ -1187,7 +1187,7 @@ checksums:
environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc
environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83
environments/surveys/edit/card_border_color: 8d7c7f4cbd99f154ce892dfa258eb504
environments/surveys/edit/card_styling: 47137a7e809b060ca94418202a8fd3c5
environments/surveys/edit/card_styling: 01e88d58219539fb831e79f0bb3ce88e
environments/surveys/edit/casual: 6534fe68718fade470a9031f7390409e
environments/surveys/edit/caution_edit_duplicate: ee93bccb34fcd707e1ef4735f1c2fc31
environments/surveys/edit/caution_edit_published_survey: faf7fc57c776f2a9104d143e20044486
@@ -1243,7 +1243,6 @@ checksums:
environments/surveys/edit/css_selector: 615e9f1b74622df29de28a5b5614c6fe
environments/surveys/edit/cta_button_label: ec070ffba38eae24751bb3a4c1e14c81
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
environments/surveys/edit/days_before_showing_this_survey_again: 354fb28c5ff076f022d82a20c749ee46
@@ -1344,9 +1343,9 @@ checksums:
environments/surveys/edit/hide_back_button_description: caaa30cf43c5611577933a1c9f44b9ee
environments/surveys/edit/hide_block_settings: c24c3d3892c251792e297cdc036d2fde
environments/surveys/edit/hide_logo: eef4de2e3fffe8cbe32bff4f6f7250d8
environments/surveys/edit/hide_logo_from_survey: 9d44321539cc2b397376a35bb8b3d1cd
environments/surveys/edit/hide_progress_bar: 7eefe7db6a051105bded521d94204933
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
environments/surveys/edit/hide_the_logo_in_this_specific_survey: 29d4c6c714886e57bc29ad292d0f5a00
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
environments/surveys/edit/how_funky_do_you_want_your_cards_in_survey_type_derived_surveys: 3cb16b37510c01af20a80f51b598346e
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
@@ -1393,7 +1392,6 @@ checksums:
environments/surveys/edit/load_segment: 5341d3de37ff10f7526152e38e25e3c5
environments/surveys/edit/logic_error_warning: 542fbb918ffdb29e6f9a4a6196ffb558
environments/surveys/edit/logic_error_warning_text: f2afad8852a95ed169a39959efbf592c
environments/surveys/edit/logo_settings: 9f54ca6684e989cc38bf177425b6d366
environments/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53
environments/surveys/edit/long_answer_toggle_description: 86bcdfeb74d9825c2f2d5a215e92d111
environments/surveys/edit/lower_label: 45985bca022d4370bd6e013af75d5160
@@ -1426,7 +1424,6 @@ checksums:
environments/surveys/edit/overwrite_global_waiting_time: 7bc23bd502b6bd048356b67acd956d9d
environments/surveys/edit/overwrite_global_waiting_time_description: 795cf6e93d4c01d2e43aa0ebab601c6e
environments/surveys/edit/overwrite_placement: d7278be243e52c5091974e0fc4a7c342
environments/surveys/edit/overwrite_survey_logo: a89cb566dfcc1559446abd8b830c84ed
environments/surveys/edit/overwrite_the_global_placement_of_the_survey: 874075712254b1ce92e099d89f675a48
environments/surveys/edit/pick_a_background_from_our_library_or_upload_your_own: b83bcbdc8131fc9524d272ff5dede754
environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028
-6
View File
@@ -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++) {
-1
View File
@@ -176,7 +176,6 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ja-JP",
"zh-Hans-CN",
"es-ES",
"sv-SE",
];
// Billing constants
+8 -36
View File
@@ -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]);
}
@@ -140,7 +140,6 @@ export const appLanguages = [
"zh-Hans-CN": "英语(美国)",
"nl-NL": "Engels (VS)",
"es-ES": "Inglés (EE.UU.)",
"sv-SE": "Engelska (USA)",
},
},
{
@@ -157,7 +156,6 @@ export const appLanguages = [
"zh-Hans-CN": "德语",
"nl-NL": "Duits",
"es-ES": "Alemán",
"sv-SE": "Tyska",
},
},
{
@@ -174,7 +172,6 @@ export const appLanguages = [
"zh-Hans-CN": "葡萄牙语(巴西)",
"nl-NL": "Portugees (Brazilië)",
"es-ES": "Portugués (Brasil)",
"sv-SE": "Portugisiska (Brasilien)",
},
},
{
@@ -191,7 +188,6 @@ export const appLanguages = [
"zh-Hans-CN": "法语",
"nl-NL": "Frans",
"es-ES": "Francés",
"sv-SE": "Franska",
},
},
{
@@ -203,12 +199,11 @@ export const appLanguages = [
"fr-FR": "Chinois (Traditionnel)",
"zh-Hant-TW": "繁體中文",
"pt-PT": "Chinês (Tradicional)",
"ro-RO": "Chineza (Tradițională)",
"ro-RO": "Chineză (Tradicională)",
"ja-JP": "中国語(繁体字)",
"zh-Hans-CN": "繁体中文",
"nl-NL": "Chinees (Traditioneel)",
"es-ES": "Chino (Tradicional)",
"sv-SE": "Kinesiska (traditionell)",
},
},
{
@@ -225,7 +220,6 @@ export const appLanguages = [
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
"nl-NL": "Portugees (Portugal)",
"es-ES": "Portugués (Portugal)",
"sv-SE": "Portugisiska (Portugal)",
},
},
{
@@ -242,7 +236,6 @@ export const appLanguages = [
"zh-Hans-CN": "罗马尼亚语",
"nl-NL": "Roemeens",
"es-ES": "Rumano",
"sv-SE": "Rumänska",
},
},
{
@@ -259,7 +252,6 @@ export const appLanguages = [
"zh-Hans-CN": "日语",
"nl-NL": "Japans",
"es-ES": "Japonés",
"sv-SE": "Japanska",
},
},
{
@@ -271,12 +263,11 @@ export const appLanguages = [
"fr-FR": "Chinois (Simplifié)",
"zh-Hant-TW": "簡體中文",
"pt-PT": "Chinês (Simplificado)",
"ro-RO": "Chineza (Simplificată)",
"ro-RO": "Chineză (Simplificată)",
"ja-JP": "中国語(簡体字)",
"zh-Hans-CN": "简体中文",
"nl-NL": "Chinees (Vereenvoudigd)",
"es-ES": "Chino (Simplificado)",
"sv-SE": "Kinesiska (förenklad)",
},
},
{
@@ -288,12 +279,11 @@ export const appLanguages = [
"fr-FR": "Néerlandais",
"zh-Hant-TW": "荷蘭語",
"pt-PT": "Holandês",
"ro-RO": "Olandeza",
"ro-RO": "Olandeză",
"ja-JP": "オランダ語",
"zh-Hans-CN": "荷兰语",
"nl-NL": "Nederlands",
"es-ES": "Neerlandés",
"sv-SE": "Nederländska",
},
},
{
@@ -310,24 +300,6 @@ export const appLanguages = [
"zh-Hans-CN": "西班牙语",
"nl-NL": "Spaans",
"es-ES": "Español",
"sv-SE": "Spanska",
},
},
{
code: "sv-SE",
label: {
"en-US": "Swedish",
"de-DE": "Schwedisch",
"pt-BR": "Sueco",
"fr-FR": "Suédois",
"zh-Hant-TW": "瑞典語",
"pt-PT": "Sueco",
"ro-RO": "Suedeză",
"ja-JP": "スウェーデン語",
"zh-Hans-CN": "瑞典语",
"nl-NL": "Zweeds",
"es-ES": "Sueco",
"sv-SE": "Svenska",
},
},
];
-328
View File
@@ -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);
});
});
});
+5 -10
View File
@@ -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,
+1 -1
View File
@@ -174,7 +174,7 @@ describe("Response Processing", () => {
});
});
describe("getElementResponseMapping", () => {
describe("getQuestionResponseMapping", () => {
const mockSurvey = {
id: "survey1",
type: "link" as const,
+1
View File
@@ -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"'
);
-6
View File
@@ -69,12 +69,6 @@ describe("Time Utilities", () => {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "de-DE")).toBe("vor etwa 1 Stunde");
});
test("should format time since in Swedish", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan");
});
});
describe("timeSinceDate", () => {
+1 -3
View File
@@ -1,5 +1,5 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, sv, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string | null) => {
@@ -93,8 +93,6 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
return fr;
case "nl-NL":
return nl;
case "sv-SE":
return sv;
case "zh-Hant-TW":
return zhTW;
case "pt-PT":
-22
View File
@@ -1,7 +1,6 @@
import * as nextHeaders from "next/headers";
import { describe, expect, test, vi } from "vitest";
import { AVAILABLE_LOCALES, DEFAULT_LOCALE } from "@/lib/constants";
import { appLanguages } from "@/lib/i18n/utils";
import { findMatchingLocale } from "./locale";
// Mock the Next.js headers function
@@ -85,25 +84,4 @@ describe("locale", () => {
expect(result).toBe(germanLocale);
expect(nextHeaders.headers).toHaveBeenCalled();
});
test("Swedish locale (sv-SE) is available and selectable", async () => {
// Verify sv-SE is in AVAILABLE_LOCALES
expect(AVAILABLE_LOCALES).toContain("sv-SE");
// Verify Swedish has a language entry with proper labels
const swedishLanguage = appLanguages.find((lang) => lang.code === "sv-SE");
expect(swedishLanguage).toBeDefined();
expect(swedishLanguage?.label["en-US"]).toBe("Swedish");
expect(swedishLanguage?.label["sv-SE"]).toBe("Svenska");
// Verify the locale can be matched from Accept-Language header
vi.mocked(nextHeaders.headers).mockReturnValue({
get: vi.fn().mockReturnValue("sv-SE,en-US"),
} as any);
const result = await findMatchingLocale();
expect(result).toBe("sv-SE");
expect(nextHeaders.headers).toHaveBeenCalled();
});
});
+4 -7
View File
@@ -422,7 +422,6 @@
"updated": "Aktualisiert",
"updated_at": "Aktualisiert am",
"upload": "Hochladen",
"upload_failed": "Upload fehlgeschlagen. Bitte versuche es erneut.",
"upload_input_description": "Klicke oder ziehe, um Dateien hochzuladen.",
"url": "URL",
"user": "Benutzer",
@@ -1256,7 +1255,8 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
"automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach",
"back_button_label": "Zurück\"- Button ",
"background_styling": "Hintergrundgestaltung",
"background_styling": "Hintergründe",
"block_deleted": "Block gelöscht.",
"block_duplicated": "Block dupliziert.",
"bold": "Fett",
"brand_color": "Markenfarbe",
@@ -1272,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
"card_background_color": "Hintergrundfarbe der Karte",
"card_border_color": "Farbe des Kartenrandes",
"card_styling": "Kartengestaltung",
"card_styling": "Kartenstil",
"casual": "Lässig",
"caution_edit_duplicate": "Duplizieren & bearbeiten",
"caution_edit_published_survey": "Eine veröffentlichte Umfrage bearbeiten?",
@@ -1328,7 +1328,6 @@
"css_selector": "CSS-Selektor",
"cta_button_label": "\"CTA\"-Schaltflächen-Beschriftung",
"custom_hostname": "Benutzerdefinierter Hostname",
"customize_survey_logo": "Umfragelogo anpassen",
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
"date_format": "Datumsformat",
"days_before_showing_this_survey_again": "Tage nachdem eine beliebige Umfrage angezeigt wurde, bevor diese Umfrage erscheinen kann.",
@@ -1429,9 +1428,9 @@
"hide_back_button_description": "Den Zurück-Button in der Umfrage nicht anzeigen",
"hide_block_settings": "Block-Einstellungen ausblenden",
"hide_logo": "Logo verstecken",
"hide_logo_from_survey": "Logo in dieser Umfrage ausblenden",
"hide_progress_bar": "Fortschrittsbalken ausblenden",
"hide_question_settings": "Frageeinstellungen ausblenden",
"hide_the_logo_in_this_specific_survey": "Logo in dieser speziellen Umfrage verstecken",
"hostname": "Hostname",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
"if_you_need_more_please": "Wenn Du mehr brauchst, bitte",
@@ -1478,7 +1477,6 @@
"load_segment": "Segment laden",
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
"logic_error_warning_text": "Das Ändern des Fragetypen entfernt die Logikbedingungen von dieser Frage",
"logo_settings": "Logo-Einstellungen",
"long_answer": "Lange Antwort",
"long_answer_toggle_description": "Ermöglichen Sie den Befragten, längere Antworten über mehrere Zeilen zu schreiben.",
"lower_label": "Unteres Label",
@@ -1511,7 +1509,6 @@
"overwrite_global_waiting_time": "Benutzerdefinierte Wartezeit festlegen",
"overwrite_global_waiting_time_description": "Die Projektkonfiguration nur für diese Umfrage überschreiben.",
"overwrite_placement": "Platzierung überschreiben",
"overwrite_survey_logo": "Benutzerdefiniertes Umfragelogo festlegen",
"overwrite_the_global_placement_of_the_survey": "Platzierung für diese Umfrage überschreiben",
"pick_a_background_from_our_library_or_upload_your_own": "Wähle einen Hintergrund aus oder lade deinen eigenen hoch.",
"picture_idx": "Bild {idx}",
+4 -7
View File
@@ -422,7 +422,6 @@
"updated": "Updated",
"updated_at": "Updated at",
"upload": "Upload",
"upload_failed": "Upload failed. Please try again.",
"upload_input_description": "Click or drag to upload files.",
"url": "URL",
"user": "User",
@@ -1256,7 +1255,8 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Automatically close the survey if the user does not respond after certain number of seconds.",
"automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after",
"back_button_label": "\"Back\" Button Label",
"background_styling": "Background styling",
"background_styling": "Background Styling",
"block_deleted": "Block deleted.",
"block_duplicated": "Block duplicated.",
"bold": "Bold",
"brand_color": "Brand color",
@@ -1272,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
"card_background_color": "Card background color",
"card_border_color": "Card border color",
"card_styling": "Card styling",
"card_styling": "Card Styling",
"casual": "Casual",
"caution_edit_duplicate": "Duplicate & edit",
"caution_edit_published_survey": "Edit a published survey?",
@@ -1328,7 +1328,6 @@
"css_selector": "CSS Selector",
"cta_button_label": "\"CTA\" button label",
"custom_hostname": "Custom hostname",
"customize_survey_logo": "Customize the survey logo",
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format",
"days_before_showing_this_survey_again": "days after any survey is shown before this survey can appear.",
@@ -1429,9 +1428,9 @@
"hide_back_button_description": "Do not display the back button in the survey",
"hide_block_settings": "Hide Block settings",
"hide_logo": "Hide logo",
"hide_logo_from_survey": "Hide logo from this survey",
"hide_progress_bar": "Hide progress bar",
"hide_question_settings": "Hide Question settings",
"hide_the_logo_in_this_specific_survey": "Hide the logo in this specific survey",
"hostname": "Hostname",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "How funky do you want your cards in {surveyTypeDerived} Surveys",
"if_you_need_more_please": "If you need more, please",
@@ -1478,7 +1477,6 @@
"load_segment": "Load segment",
"logic_error_warning": "Changing will cause logic errors",
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
"logo_settings": "Logo settings",
"long_answer": "Long answer",
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
"lower_label": "Lower Label",
@@ -1511,7 +1509,6 @@
"overwrite_global_waiting_time": "Set custom waiting time",
"overwrite_global_waiting_time_description": "Override the project configuration for this survey only.",
"overwrite_placement": "Overwrite placement",
"overwrite_survey_logo": "Set custom survey logo",
"overwrite_the_global_placement_of_the_survey": "Overwrite the global placement of the survey",
"pick_a_background_from_our_library_or_upload_your_own": "Pick a background from our library or upload your own.",
"picture_idx": "Picture {idx}",
+4 -7
View File
@@ -422,7 +422,6 @@
"updated": "Actualizado",
"updated_at": "Actualizado el",
"upload": "Subir",
"upload_failed": "La subida ha fallado. Por favor, inténtalo de nuevo.",
"upload_input_description": "Haz clic o arrastra para subir archivos.",
"url": "URL",
"user": "Usuario",
@@ -1256,7 +1255,8 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
"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 del fondo",
"background_styling": "Estilo de fondo",
"block_deleted": "Bloque eliminado.",
"block_duplicated": "Bloque duplicado.",
"bold": "Negrita",
"brand_color": "Color de marca",
@@ -1272,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "Disposición de tarjetas para encuestas de tipo {surveyTypeDerived}",
"card_background_color": "Color de fondo de la tarjeta",
"card_border_color": "Color del borde de la tarjeta",
"card_styling": "Estilo de la tarjeta",
"card_styling": "Estilo de tarjeta",
"casual": "Informal",
"caution_edit_duplicate": "Duplicar y editar",
"caution_edit_published_survey": "¿Editar una encuesta publicada?",
@@ -1328,7 +1328,6 @@
"css_selector": "Selector CSS",
"cta_button_label": "Etiqueta del botón \"CTA\"",
"custom_hostname": "Nombre de host personalizado",
"customize_survey_logo": "Personalizar el logotipo de la encuesta",
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
"date_format": "Formato de fecha",
"days_before_showing_this_survey_again": "días después de que se muestre cualquier encuesta antes de que esta encuesta pueda aparecer.",
@@ -1429,9 +1428,9 @@
"hide_back_button_description": "No mostrar el botón de retroceso en la encuesta",
"hide_block_settings": "Ocultar ajustes del bloque",
"hide_logo": "Ocultar logotipo",
"hide_logo_from_survey": "Ocultar logotipo de esta encuesta",
"hide_progress_bar": "Ocultar barra de progreso",
"hide_question_settings": "Ocultar ajustes de la pregunta",
"hide_the_logo_in_this_specific_survey": "Ocultar el logotipo en esta encuesta específica",
"hostname": "Nombre de host",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "¿Cuánto estilo quieres darle a tus tarjetas en las encuestas de tipo {surveyTypeDerived}?",
"if_you_need_more_please": "Si necesitas más, por favor",
@@ -1478,7 +1477,6 @@
"load_segment": "Cargar segmento",
"logic_error_warning": "El cambio causará errores lógicos",
"logic_error_warning_text": "Cambiar el tipo de pregunta eliminará las condiciones lógicas de esta pregunta",
"logo_settings": "Ajustes del logotipo",
"long_answer": "Respuesta larga",
"long_answer_toggle_description": "Permitir a los encuestados escribir respuestas más largas y de varias líneas.",
"lower_label": "Etiqueta inferior",
@@ -1511,7 +1509,6 @@
"overwrite_global_waiting_time": "Establecer tiempo de espera personalizado",
"overwrite_global_waiting_time_description": "Anular la configuración del proyecto solo para esta encuesta.",
"overwrite_placement": "Sobrescribir ubicación",
"overwrite_survey_logo": "Establecer logotipo personalizado para la encuesta",
"overwrite_the_global_placement_of_the_survey": "Sobrescribir la ubicación global de la encuesta",
"pick_a_background_from_our_library_or_upload_your_own": "Elige un fondo de nuestra biblioteca o sube el tuyo propio.",
"picture_idx": "Imagen {idx}",
+3 -6
View File
@@ -422,7 +422,6 @@
"updated": "Mise à jour",
"updated_at": "Mis à jour à",
"upload": "Télécharger",
"upload_failed": "Échec du téléchargement. Veuillez réessayer.",
"upload_input_description": "Cliquez ou faites glisser pour charger un fichier.",
"url": "URL",
"user": "Utilisateur",
@@ -1256,7 +1255,8 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
"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 d'arrière-plan",
"background_styling": "Style de fond",
"block_deleted": "Bloc supprimé.",
"block_duplicated": "Bloc dupliqué.",
"bold": "Gras",
"brand_color": "Couleur de marque",
@@ -1328,7 +1328,6 @@
"css_selector": "Sélecteur CSS",
"cta_button_label": "Libellé du bouton « CTA »",
"custom_hostname": "Nom d'hôte personnalisé",
"customize_survey_logo": "Personnaliser le logo de l'enquête",
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
"date_format": "Format de date",
"days_before_showing_this_survey_again": "jours après qu'une enquête soit affichée avant que cette enquête puisse apparaître.",
@@ -1429,9 +1428,9 @@
"hide_back_button_description": "Ne pas afficher le bouton retour dans l'enquête",
"hide_block_settings": "Masquer les paramètres du bloc",
"hide_logo": "Cacher le logo",
"hide_logo_from_survey": "Masquer le logo de cette enquête",
"hide_progress_bar": "Cacher la barre de progression",
"hide_question_settings": "Masquer les paramètres de la question",
"hide_the_logo_in_this_specific_survey": "Cacher le logo dans cette enquête spécifique",
"hostname": "Nom d'hôte",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}",
"if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît",
@@ -1478,7 +1477,6 @@
"load_segment": "Segment de chargement",
"logic_error_warning": "Changer causera des erreurs logiques",
"logic_error_warning_text": "Changer le type de question supprimera les conditions logiques de cette question.",
"logo_settings": "Paramètres du logo",
"long_answer": "Longue réponse",
"long_answer_toggle_description": "Permettre aux répondants d'écrire des réponses plus longues et sur plusieurs lignes.",
"lower_label": "Étiquette inférieure",
@@ -1511,7 +1509,6 @@
"overwrite_global_waiting_time": "Définir un temps d'attente personnalisé",
"overwrite_global_waiting_time_description": "Remplacer la configuration du projet pour cette enquête uniquement.",
"overwrite_placement": "Surcharge de placement",
"overwrite_survey_logo": "Définir un logo d'enquête personnalisé",
"overwrite_the_global_placement_of_the_survey": "Surcharger le placement global de l'enquête",
"pick_a_background_from_our_library_or_upload_your_own": "Choisissez un arrière-plan dans notre bibliothèque ou téléchargez le vôtre.",
"picture_idx": "Image {idx}",
+4 -7
View File
@@ -422,7 +422,6 @@
"updated": "更新済み",
"updated_at": "更新日時",
"upload": "アップロード",
"upload_failed": "アップロードに失敗しました。もう一度お試しください。",
"upload_input_description": "クリックまたはドラッグしてファイルをアップロードしてください。",
"url": "URL",
"user": "ユーザー",
@@ -1256,7 +1255,8 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
"automatically_mark_the_survey_as_complete_after": "フォームを自動的に完了としてマークする",
"back_button_label": "「戻る」ボタンのラベル",
"background_styling": "背景のスタイル設定",
"background_styling": "背景のスタイル",
"block_deleted": "ブロックが削除されました。",
"block_duplicated": "ブロックが複製されました。",
"bold": "太字",
"brand_color": "ブランドカラー",
@@ -1272,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} フォームのカード配置",
"card_background_color": "カードの背景色",
"card_border_color": "カードの枠線の色",
"card_styling": "カードのスタイル設定",
"card_styling": "カードのスタイル",
"casual": "カジュアル",
"caution_edit_duplicate": "複製して編集",
"caution_edit_published_survey": "公開済みのフォームを編集しますか?",
@@ -1328,7 +1328,6 @@
"css_selector": "CSSセレクター",
"cta_button_label": "\"CTA\"ボタンのラベル",
"custom_hostname": "カスタムホスト名",
"customize_survey_logo": "アンケートのロゴをカスタマイズする",
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
"date_format": "日付形式",
"days_before_showing_this_survey_again": "任意のフォームが表示された後、このフォームが再表示されるまでの日数。",
@@ -1429,9 +1428,9 @@
"hide_back_button_description": "フォームに「戻る」ボタンを表示しない",
"hide_block_settings": "ブロック設定を非表示",
"hide_logo": "ロゴを非表示",
"hide_logo_from_survey": "このアンケートからロゴを非表示にする",
"hide_progress_bar": "プログレスバーを非表示",
"hide_question_settings": "質問設定を非表示",
"hide_the_logo_in_this_specific_survey": "この特定のフォームでロゴを非表示にする",
"hostname": "ホスト名",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "{surveyTypeDerived} フォームのカードをどれくらいユニークにしますか",
"if_you_need_more_please": "さらに必要な場合は、",
@@ -1478,7 +1477,6 @@
"load_segment": "セグメントを読み込み",
"logic_error_warning": "変更するとロジックエラーが発生します",
"logic_error_warning_text": "質問の種類を変更すると、この質問のロジック条件が削除されます",
"logo_settings": "ロゴ設定",
"long_answer": "長文回答",
"long_answer_toggle_description": "回答者が長文の複数行の回答を書けるようにします。",
"lower_label": "下限ラベル",
@@ -1511,7 +1509,6 @@
"overwrite_global_waiting_time": "カスタム待機時間を設定する",
"overwrite_global_waiting_time_description": "このフォームのみプロジェクト設定を上書きします。",
"overwrite_placement": "配置を上書き",
"overwrite_survey_logo": "カスタムアンケートロゴを設定する",
"overwrite_the_global_placement_of_the_survey": "フォームのグローバルな配置を上書き",
"pick_a_background_from_our_library_or_upload_your_own": "ライブラリから背景を選択するか、独自にアップロードしてください。",
"picture_idx": "写真 {idx}",
+4 -7
View File
@@ -422,7 +422,6 @@
"updated": "Bijgewerkt",
"updated_at": "Bijgewerkt op",
"upload": "Uploaden",
"upload_failed": "Upload mislukt. Probeer het opnieuw.",
"upload_input_description": "Klik of sleep om bestanden te uploaden.",
"url": "URL",
"user": "Gebruiker",
@@ -1256,7 +1255,8 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
"automatically_mark_the_survey_as_complete_after": "Markeer de enquête daarna automatisch als voltooid",
"back_button_label": "Knoplabel 'Terug'",
"background_styling": "Achtergrondstijl",
"background_styling": "Achtergrondstyling",
"block_deleted": "Blok verwijderd.",
"block_duplicated": "Blok gedupliceerd.",
"bold": "Vetgedrukt",
"brand_color": "Merk kleur",
@@ -1272,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "Kaartarrangement voor {surveyTypeDerived} enquêtes",
"card_background_color": "Achtergrondkleur van de kaart",
"card_border_color": "Randkleur kaart",
"card_styling": "Kaartstijl",
"card_styling": "Kaartstyling",
"casual": "Casual",
"caution_edit_duplicate": "Dupliceren en bewerken",
"caution_edit_published_survey": "Een gepubliceerde enquête bewerken?",
@@ -1328,7 +1328,6 @@
"css_selector": "CSS-kiezer",
"cta_button_label": "\"CTA\" knoplabel",
"custom_hostname": "Aangepaste hostnaam",
"customize_survey_logo": "Pas het enquêtelogo aan",
"darken_or_lighten_background_of_your_choice": "Maak de achtergrond naar keuze donkerder of lichter.",
"date_format": "Datumformaat",
"days_before_showing_this_survey_again": "dagen nadat een enquête is getoond voordat deze enquête kan verschijnen.",
@@ -1429,9 +1428,9 @@
"hide_back_button_description": "Geef de terugknop niet weer in de enquête",
"hide_block_settings": "Blokinstellingen verbergen",
"hide_logo": "Logo verbergen",
"hide_logo_from_survey": "Verberg logo van deze enquête",
"hide_progress_bar": "Voortgangsbalk verbergen",
"hide_question_settings": "Vraaginstellingen verbergen",
"hide_the_logo_in_this_specific_survey": "Verberg het logo in deze specifieke enquête",
"hostname": "Hostnaam",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Hoe funky wil je je kaarten hebben in {surveyTypeDerived} Enquêtes",
"if_you_need_more_please": "Als u meer nodig heeft, alstublieft",
@@ -1478,7 +1477,6 @@
"load_segment": "Laadsegment",
"logic_error_warning": "Wijzigen zal logische fouten veroorzaken",
"logic_error_warning_text": "Als u het vraagtype wijzigt, worden de logische voorwaarden van deze vraag verwijderd",
"logo_settings": "Logo-instellingen",
"long_answer": "Lang antwoord",
"long_answer_toggle_description": "Sta respondenten toe om langere antwoorden met meerdere regels te schrijven.",
"lower_label": "Lager etiket",
@@ -1511,7 +1509,6 @@
"overwrite_global_waiting_time": "Stel aangepaste wachttijd in",
"overwrite_global_waiting_time_description": "Overschrijf de projectconfiguratie alleen voor deze enquête.",
"overwrite_placement": "Plaatsing overschrijven",
"overwrite_survey_logo": "Stel aangepast enquêtelogo in",
"overwrite_the_global_placement_of_the_survey": "Overschrijf de globale plaatsing van de enquête",
"pick_a_background_from_our_library_or_upload_your_own": "Kies een achtergrond uit onze bibliotheek of upload je eigen achtergrond.",
"picture_idx": "Afbeelding {idx}",
+4 -7
View File
@@ -422,7 +422,6 @@
"updated": "atualizado",
"updated_at": "Atualizado em",
"upload": "Enviar",
"upload_failed": "Falha no upload. Tente novamente.",
"upload_input_description": "Clique ou arraste para fazer o upload de arquivos.",
"url": "URL",
"user": "Usuário",
@@ -1256,7 +1255,8 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após",
"back_button_label": "Voltar",
"background_styling": "Estilo do plano de fundo",
"background_styling": "Estilo de Fundo",
"block_deleted": "Bloco excluído.",
"block_duplicated": "Bloco duplicado.",
"bold": "Negrito",
"brand_color": "Cor da marca",
@@ -1272,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão",
"card_border_color": "Cor da borda do cartão",
"card_styling": "Estilo do cartão",
"card_styling": "Estilização de Cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
"caution_edit_published_survey": "Editar uma pesquisa publicada?",
@@ -1328,7 +1328,6 @@
"css_selector": "Seletor CSS",
"cta_button_label": "Rótulo do botão \"CTA\"",
"custom_hostname": "Hostname personalizado",
"customize_survey_logo": "Personalizar o logo da pesquisa",
"darken_or_lighten_background_of_your_choice": "Escureça ou clareie o fundo da sua escolha.",
"date_format": "Formato de data",
"days_before_showing_this_survey_again": "dias após qualquer pesquisa ser mostrada antes que esta pesquisa possa aparecer.",
@@ -1429,9 +1428,9 @@
"hide_back_button_description": "Não exibir o botão de voltar na pesquisa",
"hide_block_settings": "Ocultar configurações do bloco",
"hide_logo": "Esconder logo",
"hide_logo_from_survey": "Esconder logo desta pesquisa",
"hide_progress_bar": "Esconder barra de progresso",
"hide_question_settings": "Ocultar configurações da pergunta",
"hide_the_logo_in_this_specific_survey": "Esconder o logo nessa pesquisa específica",
"hostname": "nome do host",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão descoladas você quer suas cartas em Pesquisas {surveyTypeDerived}",
"if_you_need_more_please": "Se você precisar de mais, por favor",
@@ -1478,7 +1477,6 @@
"load_segment": "segmento de carga",
"logic_error_warning": "Mudar vai causar erros de lógica",
"logic_error_warning_text": "Mudar o tipo de pergunta vai remover as condições lógicas dessa pergunta",
"logo_settings": "Configurações do logo",
"long_answer": "resposta longa",
"long_answer_toggle_description": "Permitir que os respondentes escrevam respostas mais longas e com várias linhas.",
"lower_label": "Etiqueta Inferior",
@@ -1511,7 +1509,6 @@
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para esta pesquisa.",
"overwrite_placement": "Substituir posicionamento",
"overwrite_survey_logo": "Definir logo personalizado para a pesquisa",
"overwrite_the_global_placement_of_the_survey": "Substituir a posição global da pesquisa",
"pick_a_background_from_our_library_or_upload_your_own": "Escolha um fundo da nossa biblioteca ou faça upload do seu próprio.",
"picture_idx": "Imagem {idx}",
+4 -7
View File
@@ -422,7 +422,6 @@
"updated": "Atualizado",
"updated_at": "Atualizado em",
"upload": "Carregar",
"upload_failed": "Falha no carregamento. Por favor, tente novamente.",
"upload_input_description": "Clique ou arraste para carregar ficheiros.",
"url": "URL",
"user": "Utilizador",
@@ -1256,7 +1255,8 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
"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",
"background_styling": "Estilo de Fundo",
"block_deleted": "Bloco eliminado.",
"block_duplicated": "Bloco duplicado.",
"bold": "Negrito",
"brand_color": "Cor da marca",
@@ -1272,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão",
"card_border_color": "Cor da borda do cartão",
"card_styling": "Estilo de cartão",
"card_styling": "Estilo do cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
"caution_edit_published_survey": "Editar um inquérito publicado?",
@@ -1328,7 +1328,6 @@
"css_selector": "Seletor CSS",
"cta_button_label": "Etiqueta do botão \"CTA\"",
"custom_hostname": "Nome do host personalizado",
"customize_survey_logo": "Personalizar o logótipo do inquérito",
"darken_or_lighten_background_of_your_choice": "Escurecer ou clarear o fundo da sua escolha.",
"date_format": "Formato da data",
"days_before_showing_this_survey_again": "dias após qualquer inquérito ser mostrado antes que este inquérito possa aparecer.",
@@ -1429,9 +1428,9 @@
"hide_back_button_description": "Não mostrar o botão de retroceder no inquérito",
"hide_block_settings": "Ocultar definições do bloco",
"hide_logo": "Esconder logótipo",
"hide_logo_from_survey": "Ocultar logótipo deste inquérito",
"hide_progress_bar": "Ocultar barra de progresso",
"hide_question_settings": "Ocultar definições da pergunta",
"hide_the_logo_in_this_specific_survey": "Ocultar o logótipo neste inquérito específico",
"hostname": "Nome do host",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão extravagantes quer os seus cartões em Inquéritos {surveyTypeDerived}",
"if_you_need_more_please": "Se precisar de mais, por favor",
@@ -1478,7 +1477,6 @@
"load_segment": "Carregar segmento",
"logic_error_warning": "A alteração causará erros de lógica",
"logic_error_warning_text": "Alterar o tipo de pergunta irá remover as condições lógicas desta pergunta",
"logo_settings": "Definições do logótipo",
"long_answer": "Resposta longa",
"long_answer_toggle_description": "Permitir que os inquiridos escrevam respostas mais longas e com várias linhas.",
"lower_label": "Etiqueta Inferior",
@@ -1511,7 +1509,6 @@
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para este inquérito.",
"overwrite_placement": "Substituir colocação",
"overwrite_survey_logo": "Definir logótipo de inquérito personalizado",
"overwrite_the_global_placement_of_the_survey": "Substituir a colocação global do inquérito",
"pick_a_background_from_our_library_or_upload_your_own": "Escolha um fundo da nossa biblioteca ou carregue o seu próprio.",
"picture_idx": "Imagem {idx}",
+2 -5
View File
@@ -422,7 +422,6 @@
"updated": "Actualizat",
"updated_at": "Actualizat la",
"upload": "Încărcați",
"upload_failed": "Încărcarea a eșuat. Vă rugăm să încercați din nou.",
"upload_input_description": "Faceți clic sau trageți pentru a încărca fișiere.",
"url": "URL",
"user": "Utilizator",
@@ -1257,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",
@@ -1328,7 +1328,6 @@
"css_selector": "Selector CSS",
"cta_button_label": "Eticheta butonului \"CTA\"",
"custom_hostname": "Gazdă personalizată",
"customize_survey_logo": "Personalizează logo-ul chestionarului",
"darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.",
"date_format": "Format dată",
"days_before_showing_this_survey_again": "zile după afișarea oricărui sondaj înainte ca acest sondaj să poată apărea din nou.",
@@ -1429,9 +1428,9 @@
"hide_back_button_description": "Nu afișa butonul Înapoi în sondaj",
"hide_block_settings": "Ascunde setările blocului",
"hide_logo": "Ascunde logo",
"hide_logo_from_survey": "Ascunde logo-ul din acest chestionar",
"hide_progress_bar": "Ascunde bara de progres",
"hide_question_settings": "Ascunde setările întrebării",
"hide_the_logo_in_this_specific_survey": "Ascunde logo-ul în acest chestionar specific",
"hostname": "Nume gazdă",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Cât de funky doriți să fie cardurile dumneavoastră în sondajele de tip {surveyTypeDerived}",
"if_you_need_more_please": "Dacă aveți nevoie de mai multe, vă rugăm să",
@@ -1478,7 +1477,6 @@
"load_segment": "Încarcă segment",
"logic_error_warning": "Schimbarea va provoca erori de logică",
"logic_error_warning_text": "Schimbarea tipului de întrebare va elimina condițiile de logică din această întrebare",
"logo_settings": "Setări logo",
"long_answer": "Răspuns lung",
"long_answer_toggle_description": "Permite respondenților să scrie răspunsuri mai lungi, pe mai multe rânduri.",
"lower_label": "Etichetă inferioară",
@@ -1511,7 +1509,6 @@
"overwrite_global_waiting_time": "Setează un timp de așteptare personalizat",
"overwrite_global_waiting_time_description": "Suprascrie configurația proiectului doar pentru acest sondaj.",
"overwrite_placement": "Suprascriere amplasare",
"overwrite_survey_logo": "Setează un logo personalizat pentru chestionar",
"overwrite_the_global_placement_of_the_survey": "Suprascrie amplasarea globală a sondajului",
"pick_a_background_from_our_library_or_upload_your_own": "Alege un fundal din biblioteca noastră sau încarcă unul propriu.",
"picture_idx": "Poză {idx}",
File diff suppressed because it is too large Load Diff
+4 -7
View File
@@ -422,7 +422,6 @@
"updated": "已更新",
"updated_at": "更新 于",
"upload": "上传",
"upload_failed": "上传失败,请重试。",
"upload_input_description": "点击 或 拖动 上传 文件",
"url": "URL",
"user": "用户",
@@ -1256,7 +1255,8 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
"automatically_mark_the_survey_as_complete_after": "自动 标记 调查 为 完成 在",
"back_button_label": "\"返回\" 按钮标签",
"background_styling": "背景样式",
"background_styling": "背景 样式",
"block_deleted": "区块已删除。",
"block_duplicated": "区块已复制。",
"bold": "粗体",
"brand_color": "品牌 颜色",
@@ -1272,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} 调查 的 卡片 布局",
"card_background_color": "卡片 的 背景 颜色",
"card_border_color": "卡片 的 边框 颜色",
"card_styling": "卡样式",
"card_styling": "卡 样式",
"casual": "休闲",
"caution_edit_duplicate": "复制 并 编辑",
"caution_edit_published_survey": "编辑 已 发布 的 survey?",
@@ -1328,7 +1328,6 @@
"css_selector": "CSS 选择器",
"cta_button_label": "“CTA”按钮标签",
"custom_hostname": "自 定 义 主 机 名",
"customize_survey_logo": "自定义调查 logo",
"darken_or_lighten_background_of_your_choice": "根据 您 的 选择 暗化 或 亮化 背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "在显示此调查之前,需等待的天数。",
@@ -1429,9 +1428,9 @@
"hide_back_button_description": "不 显示 调查 中 的 返回 按钮",
"hide_block_settings": "隐藏区块设置",
"hide_logo": "隐藏 徽标",
"hide_logo_from_survey": "隐藏此调查中的 logo",
"hide_progress_bar": "隐藏 进度 条",
"hide_question_settings": "隐藏问题设置",
"hide_the_logo_in_this_specific_survey": "隐藏此特定调查中的 logo",
"hostname": "主 机 名",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "在 {surveyTypeDerived} 调查 中,您 想要 卡片 多么 有趣",
"if_you_need_more_please": "如果你需要更多,请",
@@ -1478,7 +1477,6 @@
"load_segment": "载入 段落",
"logic_error_warning": "更改 将 导致 逻辑 错误",
"logic_error_warning_text": "更改问题类型 会 移除 此问题 的 逻辑条件",
"logo_settings": "Logo 设置",
"long_answer": "长答案",
"long_answer_toggle_description": "允许受访者填写较长的多行答案。",
"lower_label": "下限标签",
@@ -1511,7 +1509,6 @@
"overwrite_global_waiting_time": "设置自定义等待时间",
"overwrite_global_waiting_time_description": "仅为此调查覆盖项目配置。",
"overwrite_placement": "覆盖 放置",
"overwrite_survey_logo": "设置自定义调查 logo",
"overwrite_the_global_placement_of_the_survey": "覆盖 全局 调查 放置",
"pick_a_background_from_our_library_or_upload_your_own": "从我们的库中选择一种 背景 或 上传您自己的。",
"picture_idx": "图片 {idx}",
+4 -7
View File
@@ -422,7 +422,6 @@
"updated": "已更新",
"updated_at": "更新時間",
"upload": "上傳",
"upload_failed": "上傳失敗。請再試一次。",
"upload_input_description": "點擊或拖曳以上傳檔案。",
"url": "網址",
"user": "使用者",
@@ -1256,7 +1255,8 @@
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
"automatically_mark_the_survey_as_complete_after": "在指定時間後自動將問卷標記為完成",
"back_button_label": "「返回」按鈕標籤",
"background_styling": "背景樣式",
"background_styling": "背景樣式設定",
"block_deleted": "區塊已刪除。",
"block_duplicated": "區塊已複製。",
"bold": "粗體",
"brand_color": "品牌顏色",
@@ -1272,7 +1272,7 @@
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
"card_background_color": "卡片背景顏色",
"card_border_color": "卡片邊框顏色",
"card_styling": "卡片樣式",
"card_styling": "卡片樣式設定",
"casual": "隨意",
"caution_edit_duplicate": "複製 & 編輯",
"caution_edit_published_survey": "編輯已發佈的調查?",
@@ -1328,7 +1328,6 @@
"css_selector": "CSS 選取器",
"cta_button_label": "「CTA」按鈕標籤",
"custom_hostname": "自訂主機名稱",
"customize_survey_logo": "自訂問卷標誌",
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "在顯示此問卷之前,需等待其他問卷顯示後的天數。",
@@ -1429,9 +1428,9 @@
"hide_back_button_description": "不要在問卷中顯示返回按鈕",
"hide_block_settings": "隱藏區塊設定",
"hide_logo": "隱藏標誌",
"hide_logo_from_survey": "隱藏此問卷的標誌",
"hide_progress_bar": "隱藏進度列",
"hide_question_settings": "隱藏問題設定",
"hide_the_logo_in_this_specific_survey": "在此特定問卷中隱藏標誌",
"hostname": "主機名稱",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "您希望 '{'surveyTypeDerived'}' 問卷中的卡片有多酷炫",
"if_you_need_more_please": "如果您需要更多,請",
@@ -1478,7 +1477,6 @@
"load_segment": "載入區隔",
"logic_error_warning": "變更將導致邏輯錯誤",
"logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件",
"logo_settings": "標誌設定",
"long_answer": "長回答",
"long_answer_toggle_description": "允許受訪者撰寫較長的多行回答。",
"lower_label": "下標籤",
@@ -1511,7 +1509,6 @@
"overwrite_global_waiting_time": "設定自訂等待時間",
"overwrite_global_waiting_time_description": "僅覆蓋此問卷的專案設定。",
"overwrite_placement": "覆寫位置",
"overwrite_survey_logo": "設定自訂問卷標誌",
"overwrite_the_global_placement_of_the_survey": "覆寫問卷的整體位置",
"pick_a_background_from_our_library_or_upload_your_own": "從我們的媒體庫中選取背景或上傳您自己的背景。",
"picture_idx": "圖片 '{'idx'}'",
@@ -15,20 +15,22 @@ 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}
className="w-full truncate rounded-md p-2 text-start hover:cursor-pointer hover:bg-slate-700"
className="w-full rounded-md p-2 text-start hover:cursor-pointer hover:bg-slate-700"
onClick={() => {
setLanguage(surveyLanguage.language.code);
setShowLanguageSelect(false);
@@ -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>
@@ -48,33 +48,29 @@ export function LanguageIndicator({
<button
aria-expanded={showLanguageDropdown}
aria-haspopup="true"
className="relative z-20 flex max-w-[120px] items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
className="relative z-20 flex items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
onClick={toggleDropdown}
tabIndex={-1}
type="button">
<span className="max-w-full truncate">
{languageToBeDisplayed ? getLanguageLabel(languageToBeDisplayed.language.code, locale) : ""}
</span>
<ChevronDown className="ml-1 h-4 w-4 flex-shrink-0" />
{languageToBeDisplayed ? getLanguageLabel(languageToBeDisplayed.language.code, locale) : ""}
<ChevronDown className="ml-1 h-4 w-4" />
</button>
{showLanguageDropdown ? (
<div
className="absolute right-0 z-30 mt-1 max-h-64 w-48 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) =>
language.language.code !== languageToBeDisplayed?.language.code &&
language.enabled && (
<button
className="flex w-full rounded-sm p-1 text-left hover:bg-slate-700"
className="block w-full rounded-sm p-1 text-left hover:bg-slate-700"
key={language.language.id}
onClick={() => {
changeLanguage(language);
}}
type="button">
<span className="min-w-0 flex-1 truncate">
{getLanguageLabel(language.language.code, locale)}
</span>
{getLanguageLabel(language.language.code, locale)}
</button>
)
)}
@@ -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>
@@ -154,6 +154,7 @@ export const ThemeStyling = ({
open={cardStylingOpen}
setOpen={setCardStylingOpen}
isSettingsPage
project={project}
surveyType={previewSurveyType}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
/>
@@ -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>
@@ -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
@@ -294,29 +291,29 @@ export const BlockCard = ({
open={!isBlockCollapsed}
onOpenChange={() => setIsBlockCollapsed(!isBlockCollapsed)}
className={cn(isBlockCollapsed ? "h-full" : "")}>
<Collapsible.CollapsibleTrigger asChild>
<div className="block h-full w-full cursor-pointer hover:bg-slate-100">
<div className="flex h-full items-center justify-between px-4 py-2">
<div className="flex items-center gap-2">
<div>
<h4 className="text-sm font-medium text-slate-700">{block.name}</h4>
<p className="text-xs text-slate-500">
{blockElementsCount} {blockElementsCountText}
</p>
</div>
</div>
<Collapsible.CollapsibleTrigger
asChild
className="block h-full w-full cursor-pointer hover:bg-slate-100">
<div className="flex h-full items-center justify-between px-4 py-2">
<div className="flex items-center gap-2">
<div>
<BlockMenu
isFirstBlock={blockIdx === 0}
isLastBlock={blockIdx === totalBlocks - 1}
isOnlyBlock={totalBlocks === 1}
onDuplicate={() => duplicateBlock(block.id)}
onDelete={() => deleteBlock(block.id)}
onMoveUp={() => moveBlock(block.id, "up")}
onMoveDown={() => moveBlock(block.id, "down")}
/>
<h4 className="text-sm font-medium text-slate-700">{block.name}</h4>
<p className="text-xs text-slate-500">
{blockElementsCount} {blockElementsCountText}
</p>
</div>
</div>
<div>
<BlockMenu
isFirstBlock={blockIdx === 0}
isLastBlock={blockIdx === totalBlocks - 1}
isOnlyBlock={totalBlocks === 1}
onDuplicate={() => duplicateBlock(block.id)}
onDelete={() => deleteBlock(block.id)}
onMoveUp={() => moveBlock(block.id, "up")}
onMoveDown={() => moveBlock(block.id, "down")}
/>
</div>
</div>
</Collapsible.CollapsibleTrigger>
@@ -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("", []),
backButtonLabel: createI18nString("", []),
};
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 });
}
};
@@ -1,262 +0,0 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import Image from "next/image";
import React, { ChangeEvent, useRef, useState } from "react";
import { UseFormReturn } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker";
import { FileInput } from "@/modules/ui/components/file-input";
import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
import { Switch } from "@/modules/ui/components/switch";
type LogoSettingsCardProps = {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
environmentId: string;
form: UseFormReturn<TProjectStyling | TSurveyStyling>;
disabled?: boolean;
isStorageConfigured: boolean;
};
export const LogoSettingsCard = ({
open,
setOpen,
environmentId,
form,
disabled = false,
isStorageConfigured,
}: LogoSettingsCardProps) => {
const { t } = useTranslation();
const [parent] = useAutoAnimate();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const logoUrl = form.watch("logo")?.url;
const logoBgColor = form.watch("logo")?.bgColor;
const isBgColorEnabled = !!logoBgColor;
const isLogoHidden = form.watch("isLogoHidden");
const setLogoUrl = (url: string | undefined) => {
const currentLogo = form.getValues("logo");
form.setValue("logo", url ? { ...currentLogo, url } : undefined);
};
const setLogoBgColor = (bgColor: string | undefined) => {
const currentLogo = form.getValues("logo");
form.setValue("logo", {
...currentLogo,
url: logoUrl,
bgColor,
});
};
const handleFileInputChange = async (files: string[]) => {
if (files.length > 0) {
setLogoUrl(files[0]);
}
};
const handleHiddenFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
if (!isStorageConfigured) {
showStorageNotConfiguredToast();
return;
}
const file = event.target.files?.[0];
if (!file) return;
setIsLoading(true);
try {
const uploadResult = await handleFileUpload(file, environmentId);
if (uploadResult.error) {
toast.error(t("common.upload_failed"));
return;
}
setLogoUrl(uploadResult.url);
} catch {
toast.error(t("common.upload_failed"));
} finally {
setIsLoading(false);
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleRemoveLogo = () => {
form.setValue("logo", undefined);
};
const toggleBackgroundColor = (enabled: boolean) => {
setLogoBgColor(enabled ? logoBgColor || "#f8f8f8" : undefined);
};
const handleBgColorChange = (color: string) => {
setLogoBgColor(color);
};
return (
<Collapsible.Root
open={open}
onOpenChange={(openState) => {
if (disabled) return;
setOpen(openState);
}}
className="w-full rounded-lg border border-slate-300 bg-white">
<Collapsible.CollapsibleTrigger
asChild
disabled={disabled}
className={cn(
"w-full cursor-pointer rounded-lg hover:bg-slate-50",
disabled && "cursor-not-allowed opacity-60 hover:bg-white"
)}>
<div className="inline-flex w-full px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
<div>
<p className="text-base font-semibold text-slate-800">
{t("environments.surveys.edit.logo_settings")}
</p>
<p className="mt-1 text-sm text-slate-500">
{t("environments.surveys.edit.customize_survey_logo")}
</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="flex flex-col" ref={parent}>
<hr className="py-1 text-slate-600" />
<div className="flex flex-col gap-6 p-6 pt-2">
<FormField
control={form.control}
name="isLogoHidden"
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Switch checked={!!field.value} onCheckedChange={field.onChange} disabled={disabled} />
</FormControl>
<div>
<FormLabel className="text-base font-semibold text-slate-900">
{t("environments.surveys.edit.hide_logo")}
</FormLabel>
<FormDescription className="text-sm text-slate-800">
{t("environments.surveys.edit.hide_logo_from_survey")}
</FormDescription>
</div>
</FormItem>
)}
/>
{!isLogoHidden && (
<div className="space-y-4">
<div className="font-medium text-slate-800">
{t("environments.surveys.edit.overwrite_survey_logo")}
</div>
{/* Hidden file input for replacing logo */}
<Input
ref={fileInputRef}
type="file"
accept="image/jpeg, image/png, image/webp, image/heic"
className="hidden"
disabled={disabled}
onChange={handleHiddenFileChange}
/>
{logoUrl ? (
<>
<div className="flex items-center gap-4">
<Image
src={logoUrl}
alt="Survey Logo"
width={256}
height={56}
style={{ backgroundColor: logoBgColor || undefined }}
className="h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
</div>
<div className="flex gap-2">
<Button
type="button"
onClick={() => {
if (!isStorageConfigured) {
showStorageNotConfiguredToast();
return;
}
fileInputRef.current?.click();
}}
variant="secondary"
size="sm"
disabled={disabled || isLoading}>
{t("environments.project.look.replace_logo")}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleRemoveLogo}
disabled={disabled}>
{t("environments.project.look.remove_logo")}
</Button>
</div>
<AdvancedOptionToggle
isChecked={isBgColorEnabled}
onToggle={toggleBackgroundColor}
htmlId="surveyLogoBgColor"
title={t("environments.project.look.add_background_color")}
description={t("environments.project.look.add_background_color_description")}
childBorder
customContainerClass="p-0"
childrenContainerClass="overflow-visible"
disabled={disabled}>
{isBgColorEnabled && (
<div className="px-2">
<ColorPicker
color={logoBgColor || "#f8f8f8"}
onChange={handleBgColorChange}
disabled={disabled}
/>
</div>
)}
</AdvancedOptionToggle>
</>
) : (
<FileInput
id="survey-logo-input"
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={environmentId}
onFileUpload={handleFileInputChange}
disabled={disabled}
maxSizeInMB={5}
isStorageConfigured={isStorageConfigured}
/>
)}
</div>
)}
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
};
@@ -11,7 +11,6 @@ import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { defaultStyling } from "@/lib/styling/constants";
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
import { LogoSettingsCard } from "@/modules/survey/editor/components/logo-settings-card";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { BackgroundStylingCard } from "@/modules/ui/components/background-styling-card";
import { Button } from "@/modules/ui/components/button";
@@ -65,7 +64,6 @@ export const StylingView = ({
const setOverwriteThemeStyling = (value: boolean) => form.setValue("overwriteThemeStyling", value);
const [formStylingOpen, setFormStylingOpen] = useState(false);
const [logoSettingsOpen, setLogoSettingsOpen] = useState(false);
const [cardStylingOpen, setCardStylingOpen] = useState(false);
const [stylingOpen, setStylingOpen] = useState(false);
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
@@ -90,7 +88,6 @@ export const StylingView = ({
useEffect(() => {
if (!overwriteThemeStyling) {
setFormStylingOpen(false);
setLogoSettingsOpen(false);
setCardStylingOpen(false);
setStylingOpen(false);
}
@@ -201,31 +198,21 @@ export const StylingView = ({
setOpen={setCardStylingOpen}
surveyType={localSurvey.type}
disabled={!overwriteThemeStyling}
project={project}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
/>
{localSurvey.type === "link" && (
<>
<BackgroundStylingCard
open={stylingOpen}
setOpen={setStylingOpen}
environmentId={environmentId}
colors={colors}
disabled={!overwriteThemeStyling}
isUnsplashConfigured={isUnsplashConfigured}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
isStorageConfigured={isStorageConfigured}
/>
<LogoSettingsCard
open={logoSettingsOpen}
setOpen={setLogoSettingsOpen}
disabled={!overwriteThemeStyling}
environmentId={environmentId}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
isStorageConfigured={isStorageConfigured}
/>
</>
<BackgroundStylingCard
open={stylingOpen}
setOpen={setStylingOpen}
environmentId={environmentId}
colors={colors}
disabled={!overwriteThemeStyling}
isUnsplashConfigured={isUnsplashConfigured}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
isStorageConfigured={isStorageConfigured}
/>
)}
{!isCxMode && (
@@ -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");
}
+5 -30
View File
@@ -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";
@@ -79,7 +79,7 @@ export const LinkSurveyWrapper = ({
styling={styling}
onBackgroundLoaded={handleBackgroundLoaded}>
<div className="flex max-h-dvh min-h-dvh items-center justify-center overflow-clip">
{!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && <ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} />}
{!styling.isLogoHidden && project.logo?.url && <ClientLogo projectLogo={project.logo} />}
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
{isPreview && (
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
+64 -134
View File
@@ -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;
}
};
@@ -1,6 +1,7 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Project } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import React from "react";
@@ -10,6 +11,7 @@ import { TProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
import { Badge } from "@/modules/ui/components/badge";
import { CardArrangementTabs } from "@/modules/ui/components/card-arrangement-tabs";
import { ColorPicker } from "@/modules/ui/components/color-picker";
import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
@@ -22,6 +24,7 @@ type CardStylingSettingsProps = {
isSettingsPage?: boolean;
surveyType?: TSurveyType;
disabled?: boolean;
project: Project;
form: UseFormReturn<TProjectStyling | TSurveyStyling>;
};
@@ -30,12 +33,14 @@ export const CardStylingSettings = ({
surveyType,
disabled,
open,
project,
setOpen,
form,
}: CardStylingSettingsProps) => {
const { t } = useTranslation();
const isAppSurvey = surveyType === "app";
const surveyTypeDerived = isAppSurvey ? "App" : "Link";
const isLogoVisible = !!project.logo?.url;
const linkCardArrangement = form.watch("cardArrangement.linkSurveys") ?? "straight";
const appCardArrangement = form.watch("cardArrangement.appSurveys") ?? "straight";
@@ -217,6 +222,35 @@ export const CardStylingSettings = ({
/>
</div>
{isLogoVisible && (!surveyType || surveyType === "link") && !isSettingsPage && (
<div className="flex items-center space-x-1">
<FormField
control={form.control}
name="isLogoHidden"
render={({ field }) => (
<FormItem className="flex w-full items-center gap-2 space-y-0">
<FormControl>
<Switch
id="isLogoHidden"
checked={!!field.value}
onCheckedChange={(checked) => field.onChange(checked)}
/>
</FormControl>
<div>
<FormLabel>
{t("environments.surveys.edit.hide_logo")}
<Badge type="gray" size="normal" text={t("common.link_surveys")} />
</FormLabel>
<FormDescription>
{t("environments.surveys.edit.hide_the_logo_in_this_specific_survey")}
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
)}
{(!surveyType || isAppSurvey) && (
<div className="flex max-w-xs flex-col gap-4">
<div className="flex items-center space-x-1">
@@ -5,24 +5,20 @@ import { ArrowUpRight } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TLogo } from "@formbricks/types/styling";
import { cn } from "@/lib/cn";
interface ClientLogoProps {
environmentId?: string;
projectLogo: Project["logo"] | null;
surveyLogo?: TLogo | null;
previewSurvey?: boolean;
}
export const ClientLogo = ({ environmentId, projectLogo, surveyLogo, previewSurvey = false }: ClientLogoProps) => {
export const ClientLogo = ({ environmentId, projectLogo, previewSurvey = false }: ClientLogoProps) => {
const { t } = useTranslation();
const logoToUse = surveyLogo?.url ? surveyLogo : projectLogo;
return (
<div
className={cn(previewSurvey ? "" : "left-3 top-3 md:left-7 md:top-7", "group absolute z-0 rounded-lg")}
style={{ backgroundColor: logoToUse?.bgColor }}>
style={{ backgroundColor: projectLogo?.bgColor }}>
{previewSurvey && environmentId && (
<Link
href={`/environments/${environmentId}/project/look`}
@@ -34,9 +30,9 @@ export const ClientLogo = ({ environmentId, projectLogo, surveyLogo, previewSurv
/>
</Link>
)}
{logoToUse?.url ? (
{projectLogo?.url ? (
<Image
src={logoToUse?.url}
src={projectLogo?.url}
className={cn(
previewSurvey ? "max-h-12" : "max-h-16 md:max-h-20",
"w-auto max-w-40 object-contain p-1 md:max-w-56"
@@ -117,7 +117,7 @@ export const ConfirmationModal = ({
<CircleAlert className="h-4 w-4 text-slate-500" />
)}
<div className="flex flex-col">
<DialogTitle className="w-full truncate text-left">{title}</DialogTitle>
<DialogTitle className="w-full text-left">{title}</DialogTitle>
<DialogDescription className="w-full text-left">
<span className="mt-2 whitespace-pre-wrap">
{description ?? t("environments.project.general.this_action_cannot_be_undone")}
@@ -263,12 +263,7 @@ export const PreviewSurvey = ({
<div className="flex h-full w-full flex-col justify-center px-1">
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo
environmentId={environment.id}
projectLogo={project.logo}
surveyLogo={styling.logo}
previewSurvey
/>
<ClientLogo environmentId={environment.id} projectLogo={project.logo} previewSurvey />
)}
</div>
<div className="z-10 w-full rounded-lg border border-transparent">
@@ -368,12 +363,7 @@ export const PreviewSurvey = ({
isEditorView>
<div className="absolute left-5 top-5">
{!styling.isLogoHidden && (
<ClientLogo
environmentId={environment.id}
projectLogo={project.logo}
surveyLogo={styling.logo}
previewSurvey
/>
<ClientLogo environmentId={environment.id} projectLogo={project.logo} previewSurvey />
)}
</div>
<div className="z-0 w-full max-w-4xl rounded-lg border-transparent">
+3 -4
View File
@@ -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",
@@ -102,11 +101,11 @@
"lucide-react": "0.507.0",
"markdown-it": "14.1.0",
"mime-types": "3.0.1",
"next": "15.5.7",
"next": "15.5.6",
"next-auth": "4.24.12",
"next-safe-action": "7.10.8",
"node-fetch": "3.3.2",
"nodemailer": "7.0.11",
"nodemailer": "7.0.9",
"otplib": "12.0.1",
"papaparse": "5.5.2",
"prismjs": "1.30.0",
+17 -25
View File
@@ -62,7 +62,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect(page.getByPlaceholder(surveys.createAndSubmit.openTextQuestion.placeholder)).toBeVisible();
await page
.getByPlaceholder(surveys.createAndSubmit.openTextQuestion.placeholder)
.fill("Open Text answer");
.fill("This is my Open Text answer");
await page.locator("#questionCard-0").getByRole("button", { name: "Next" }).click();
// Single Select Question
@@ -116,7 +116,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("radio", { name: "Rate 3 out of" }).check();
await page.locator("path").nth(3).click();
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
// NPS Question
@@ -212,9 +212,11 @@ test.describe("Survey Create & Submit Response without logic", async () => {
// Address Question
await expect(page.getByText(surveys.createAndSubmit.address.question)).toBeVisible();
await expect(page.getByLabel(surveys.createAndSubmit.address.placeholder.addressLine1)).toBeVisible();
await page.getByLabel(surveys.createAndSubmit.address.placeholder.addressLine1).fill("Address");
await page
.getByLabel(surveys.createAndSubmit.address.placeholder.addressLine1)
.fill("This is my Address");
await expect(page.getByLabel(surveys.createAndSubmit.address.placeholder.city)).toBeVisible();
await page.getByLabel(surveys.createAndSubmit.address.placeholder.city).fill("city");
await page.getByLabel(surveys.createAndSubmit.address.placeholder.city).fill("This is my city");
await expect(page.getByLabel(surveys.createAndSubmit.address.placeholder.zip)).toBeVisible();
await page.getByLabel(surveys.createAndSubmit.address.placeholder.zip).fill("12345");
await page.locator("#questionCard-10").getByRole("button", { name: "Next" }).click();
@@ -230,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" });
});
@@ -783,7 +785,7 @@ test.describe("Testing Survey with advanced logic", async () => {
).toBeVisible();
await page
.getByPlaceholder(surveys.createWithLogicAndSubmit.openTextQuestion.placeholder)
.fill("Open Text answer");
.fill("This is my Open Text answer");
await page.locator("#questionCard-0").getByRole("button", { name: "Next" }).click();
// Single Select Question
@@ -856,9 +858,10 @@ test.describe("Testing Survey with advanced logic", async () => {
await expect(
page.locator("#questionCard-4").getByText(surveys.createWithLogicAndSubmit.ratingQuestion.highLabel)
).toBeVisible();
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
await page.getByRole("radio", { name: "Rate 4 out of" }).check();
await page.getByRole("group", { name: "Choices" }).locator("path").nth(3).click();
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
// NPS Question
@@ -969,12 +972,14 @@ test.describe("Testing Survey with advanced logic", async () => {
).toBeVisible();
await page
.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.addressLine1)
.fill("Address");
.fill("This is my Address");
await expect(page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.city)).toBeVisible();
await page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.city).fill("city");
await page
.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.city)
.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" });
@@ -992,26 +997,13 @@ test.describe("Testing Survey with advanced logic", async () => {
const updatedUrl = currentUrl.replace("summary?share=true", "responses");
await page.goto(updatedUrl);
await page.waitForSelector("table#response-table");
await page.waitForSelector("#response-table");
await expect(page.getByRole("cell", { name: "score" })).toBeVisible();
await page.waitForLoadState("networkidle");
await page.waitForTimeout(5000);
await page.pause();
// Look for any cell containing "32" or a score-related value
const scoreCell = page.getByRole("cell").filter({ hasText: /^32/ });
await expect(scoreCell).toBeVisible({
timeout: 15000,
});
// Look for the secret message in the table
const secretCell = page.getByRole("cell").filter({ hasText: /This is a secret message for e2e tests/ });
await expect(secretCell).toBeVisible({
timeout: 15000,
});
await expect(page.getByRole("cell", { name: "32", exact: true })).toBeVisible();
});
});
});
+1 -1
View File
@@ -656,7 +656,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
await page.locator("#action-2-operator").click();
await page.getByRole("option", { name: "Assign =" }).click();
await page.locator("#action-2-value-input").click();
await page.locator("#action-2-value-input").fill("This ");
await page.locator("#action-2-value-input").fill("1");
// Close Block 1 settings before moving to Block 2
await page
.locator("div")
+49 -49
View File
@@ -106,51 +106,51 @@ export const surveys = {
createAndSubmit: {
welcomeCard: {
headline: "Welcome to My Testing Survey Welcome Card!",
description: "the description of my Welcome Card!",
description: "This is the description of my Welcome Card!",
},
openTextQuestion: {
question: "Open Text Question",
description: "Open Text Description",
placeholder: "Placeholder",
question: "This is my Open Text Question",
description: "This is my Open Text Description",
placeholder: "This is my Placeholder",
},
singleSelectQuestion: {
question: "Single Select Question",
description: "Single Select Description",
question: "This is my Single Select Question",
description: "This is my Single Select Description",
options: ["Option 1", "Option 2"],
},
multiSelectQuestion: {
question: "Multi Select Question",
description: "Multi Select Description",
question: "This is my Multi Select Question",
description: "This is Multi Select Description",
options: ["Option 1", "Option 2", "Option 3"],
},
ratingQuestion: {
question: "Rating Question",
description: "Rating Description",
question: "This is my Rating Question",
description: "This is Rating Description",
lowLabel: "My Lower Label",
highLabel: "My Upper Label",
},
npsQuestion: {
question: "NPS Question",
question: "This is my NPS Question",
lowLabel: "My Lower Label",
highLabel: "My Upper Label",
},
ctaQuestion: {
question: "CTA Question",
question: "This is my CTA Question",
buttonLabel: "My Button Label",
},
consentQuestion: {
question: "Consent Question",
question: "This is my Consent Question",
checkboxLabel: "My Checkbox Label",
},
pictureSelectQuestion: {
question: "Picture Select Question",
description: "Picture Select Description",
question: "This is my Picture Select Question",
description: "This is Picture Select Description",
},
dateQuestion: {
question: "Date Question",
question: "This is my Date Question",
},
fileUploadQuestion: {
question: "File Upload Question",
question: "This is my File Upload Question",
},
matrix: {
question: "How much do you love these flowers?",
@@ -178,57 +178,57 @@ export const surveys = {
createWithLogicAndSubmit: {
welcomeCard: {
headline: "Welcome to My Testing Survey Welcome Card!",
description: "the description of my Welcome Card!",
description: "This is the description of my Welcome Card!",
},
openTextQuestion: {
question: "Open Text Question",
description: "Open Text Description",
placeholder: "Placeholder",
question: "This is my Open Text Question",
description: "This is my Open Text Description",
placeholder: "This is my Placeholder",
},
singleSelectQuestion: {
question: "Single Select Question",
description: "Single Select Description",
question: "This is my Single Select Question",
description: "This is my Single Select Description",
options: ["Option 1", "Option 2"],
},
multiSelectQuestion: {
question: "Multi Select Question",
description: "Multi Select Description",
question: "This is my Multi Select Question",
description: "This is Multi Select Description",
options: ["Option 1", "Option 2", "Option 3"],
},
ratingQuestion: {
question: "Rating Question",
description: "Rating Description",
question: "This is my Rating Question",
description: "This is Rating Description",
lowLabel: "My Lower Label",
highLabel: "My Upper Label",
},
npsQuestion: {
question: "NPS Question",
question: "This is my NPS Question",
lowLabel: "My Lower Label",
highLabel: "My Upper Label",
},
ctaQuestion: {
question: "CTA Question",
question: "This is my CTA Question",
buttonLabel: "My Button Label",
},
consentQuestion: {
question: "Consent Question",
question: "This is my Consent Question",
checkboxLabel: "My Checkbox Label",
},
pictureSelectQuestion: {
question: "Picture Select Question",
description: "Picture Select Description",
question: "This is my Picture Select Question",
description: "This is Picture Select Description",
},
fileUploadQuestion: {
question: "File Upload Question",
question: "This is my File Upload Question",
},
date: {
question: "Date Question",
question: "This is my Date Question",
},
cal: {
question: "cal Question",
question: "This is my cal Question",
},
matrix: {
question: "Matrix Question",
question: "This is my Matrix Question",
description: "0: Not at all, 3: Love it",
rows: ["Roses", "Trees", "Ocean"],
columns: ["0", "1", "2", "3"],
@@ -242,7 +242,7 @@ export const surveys = {
},
},
ranking: {
question: "Ranking Question",
question: "This is my Ranking Question",
choices: ["Work", "Money", "Travel", "Family", "Friends"],
},
endingCard: {
@@ -342,12 +342,12 @@ export const actions = {
noCode: {
click: {
name: "Create Click Action (CSS Selector)",
description: "Create Action (click, CSS Selector)",
description: "This is my Create Action (click, CSS Selector)",
selector: ".my-custom-class",
},
pageView: {
name: "Create Page view Action (specific Page URL)",
description: "Create Action (Page view)",
description: "This is my Create Action (Page view)",
matcher: {
label: "Contains",
value: "custom-url",
@@ -355,16 +355,16 @@ export const actions = {
},
exitIntent: {
name: "Create Exit Intent Action",
description: "Create Action (Exit Intent)",
description: "This is my Create Action (Exit Intent)",
},
fiftyPercentScroll: {
name: "Create 50% Scroll Action",
description: "Create Action (50% Scroll)",
description: "This is my Create Action (50% Scroll)",
},
},
code: {
name: "Create Action (Code)",
description: "Create Action (Code)",
description: "This is my Create Action (Code)",
key: "Create Action (Code)",
},
},
@@ -372,12 +372,12 @@ export const actions = {
noCode: {
click: {
name: "Edit Click Action (CSS Selector)",
description: "Edit Action (click, CSS Selector)",
description: "This is my Edit Action (click, CSS Selector)",
selector: ".my-custom-class-edited",
},
pageView: {
name: "Edit Page view Action (specific Page URL)",
description: "Edit Action (Page view)",
description: "This is my Edit Action (Page view)",
matcher: {
label: "Starts with",
value: "custom-url0-edited",
@@ -386,26 +386,26 @@ export const actions = {
},
exitIntent: {
name: "Edit Exit Intent Action",
description: "Edit Action (Exit Intent)",
description: "This is my Edit Action (Exit Intent)",
},
fiftyPercentScroll: {
name: "Edit 50% Scroll Action",
description: "Edit Action (50% Scroll)",
description: "This is my Edit Action (50% Scroll)",
},
},
code: {
description: "Edit Action (Code)",
description: "This is my Edit Action (Code)",
},
},
delete: {
noCode: {
name: "Delete click Action (CSS Selector)",
description: "Delete Action (CSS Selector)",
description: "This is my Delete Action (CSS Selector)",
selector: ".my-custom-class-deleted",
},
code: {
name: "Delete Action (Code)",
description: "Delete Action (Code)",
description: "This is my Delete Action (Code)",
},
},
};
-1
View File
@@ -220,7 +220,6 @@ vi.mock("@/lib/constants", () => ({
"ja-JP",
"zh-Hans-CN",
"es-ES",
"sv-SE",
],
DEFAULT_LOCALE: "en-US",
BREVO_API_KEY: "mock-brevo-api-key",
-1
View File
@@ -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.
@@ -81,13 +81,6 @@ Example of Response Created webhook payload:
}
},
"singleUseId": null,
"survey": {
"title": "Customer Satisfaction Survey",
"type": "link",
"status": "inProgress",
"createdAt": "2025-07-20T10:30:00.000Z",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {
@@ -132,13 +125,6 @@ Example of Response Updated webhook payload:
}
},
"singleUseId": null,
"survey": {
"title": "Customer Satisfaction Survey",
"type": "link",
"status": "inProgress",
"createdAt": "2025-07-20T10:30:00.000Z",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {
@@ -184,13 +170,6 @@ Example of Response Finished webhook payload:
}
},
"singleUseId": null,
"survey": {
"title": "Customer Satisfaction Survey",
"type": "link",
"status": "inProgress",
"createdAt": "2025-07-20T10:30:00.000Z",
"updatedAt": "2025-07-24T07:45:00.000Z"
},
"surveyId": "surveyId",
"tags": [],
"ttc": {
+4 -5
View File
@@ -34,16 +34,15 @@
"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"
},
"dependencies": {
"react": "19.1.2",
"react-dom": "19.1.2"
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@azure/microsoft-playwright-testing": "1.0.0-beta.7",
+8 -52
View File
@@ -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
}
-7
View File
@@ -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();
@@ -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 };
}
};
-3
View File
@@ -2,8 +2,6 @@
import { SurveyStatus, SurveyType } from "@prisma/client";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
// eslint-disable-next-line import/no-relative-packages -- Need to import from parent package
import { ZLogo } from "../../types/styling";
import { ZSurveyBlocks } from "../../types/surveys/blocks";
import {
ZSurveyEnding,
@@ -174,7 +172,6 @@ const ZSurveyBase = z.object({
background: ZSurveyStylingBackground.nullish(),
hideProgressBar: z.boolean().nullish(),
isLogoHidden: z.boolean().nullish(),
logo: ZLogo.nullish(),
})
.nullable()
.openapi({
+1 -1
View File
@@ -7,7 +7,7 @@
},
"locale": {
"source": "en",
"targets": ["de", "it", "fr", "es", "ar", "pt", "ru", "uz", "ro", "ja", "zh-Hans", "hi", "nl", "sv"]
"targets": ["de", "it", "fr", "es", "ar", "pt", "ru", "uz", "ro", "ja", "zh-Hans", "hi", "nl"]
},
"version": 1.8
}
-73
View File
@@ -1,73 +0,0 @@
{
"common": {
"and": "och",
"apply": "Tillämpa",
"auto_close_wrapper": "Automatisk stängning",
"back": "Tillbaka",
"click_or_drag_to_upload_files": "Klicka eller dra för att ladda upp filer.",
"close_survey": "Stäng enkät",
"company_logo": "Företagslogotyp",
"delete_file": "Ta bort fil",
"file_upload": "Ladda upp fil",
"finish": "Slutför",
"language_switch": "Språkväxlare",
"less_than_x_minutes": "{count, plural, one {mindre än 1 minut} other {mindre än {count} minuter}}",
"move_down": "Flytta {item} nedåt",
"move_up": "Flytta {item} uppåt",
"next": "Nästa",
"open_in_new_tab": "Öppna i ny flik",
"optional": "Valfritt",
"options": "Alternativ",
"people_responded": "{count, plural, one {1 person har svarat} other {{count} personer har svarat}}",
"please_retry_now_or_try_again_later": "Försök igen nu eller försök igen senare.",
"powered_by": "Drivs av",
"privacy_policy": "Integritetspolicy",
"protected_by_reCAPTCHA_and_the_Google": "Skyddas av reCAPTCHA och Googles",
"question": "Fråga",
"question_video": "Frågevideo",
"ranking_items": "Rangordna objekt",
"respondents_will_not_see_this_card": "Respondenter kommer inte att se detta kort",
"retry": "Försök igen",
"select_a_date": "Välj ett datum",
"select_for_ranking": "Välj {item} för rangordning",
"sending_responses": "Skickar svar...",
"takes": "Tar",
"terms_of_service": "Användarvillkor",
"the_servers_cannot_be_reached_at_the_moment": "Servrarna kan inte nås för tillfället.",
"they_will_be_redirected_immediately": "De kommer att omdirigeras omedelbart",
"upload_files_by_clicking_or_dragging_them_here": "Ladda upp filer genom att klicka eller dra dem hit",
"uploading": "Laddar upp",
"x_minutes": "{count, plural, one {1 minut} other {{count} minuter}}",
"x_plus_minutes": "{count}+ minuter",
"you_have_selected_x_date": "Du har valt {date}",
"you_have_successfully_uploaded_the_file": "Du har framgångsrikt laddat upp filen {fileName}",
"your_feedback_is_stuck": "Din feedback fastnade :("
},
"errors": {
"file_input": {
"duplicate_files": "Följande filer är redan uppladdade: {duplicateNames}. Dubbletter av filer är inte tillåtna.",
"file_size_exceeded": "Följande filer överstiger maxstorleken på {maxSizeInMB} MB och togs bort: {fileNames}",
"file_size_exceeded_alert": "Filen måste vara mindre än {maxSizeInMB} MB",
"no_valid_file_types_selected": "Inga giltiga filtyper valda. Vänligen välj en giltig filtyp.",
"only_one_file_can_be_uploaded_at_a_time": "Endast en fil kan laddas upp åt gången.",
"upload_failed": "Uppladdning misslyckades! Försök igen.",
"you_can_only_upload_a_maximum_of_files": "Du kan ladda upp maximalt {FILE_LIMIT} filer."
},
"invalid_device_error": {
"message": "Vänligen inaktivera skräppostskyddet i enkätinställningarna för att fortsätta använda denna enhet.",
"title": "Denna enhet stöder inte skräppostskydd."
},
"please_book_an_appointment": "Vänligen boka ett möte",
"please_enter_a_valid_email_address": "Vänligen ange en giltig e-postadress",
"please_enter_a_valid_phone_number": "Vänligen ange ett giltigt telefonnummer",
"please_enter_a_valid_url": "Vänligen ange en giltig URL",
"please_fill_out_this_field": "Vänligen fyll i detta fält",
"please_rank_all_items_before_submitting": "Vänligen rangordna alla objekt innan du skickar",
"please_select_a_date": "Vänligen välj ett datum",
"please_upload_a_file": "Vänligen ladda upp en fil",
"recaptcha_error": {
"message": "Ditt svar kunde inte skickas eftersom det flaggades som automatiserad aktivitet. Om du andas, försök igen.",
"title": "Vi kunde inte verifiera att du är människa."
}
}
}
@@ -43,6 +43,8 @@ export function AddressElement({
return Array.isArray(value) ? value : ["", "", "", "", "", ""];
}, [value]);
const isCurrent = element.id === currentElementId;
const fields = useMemo(
() => [
{
@@ -164,7 +166,7 @@ export function AddressElement({
handleChange(field.id, e.currentTarget.value);
}}
ref={index === 0 ? addressRef : null}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
aria-label={field.label}
dir={!safeValue[index] ? dir : "auto"}
/>
@@ -32,6 +32,7 @@ export function ConsentElement({
}: Readonly<ConsentElementProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
@@ -65,7 +66,7 @@ export function ConsentElement({
/>
<label
ref={consentRef}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
tabIndex={isCurrent ? 0 : -1}
id={`${element.id}-label`}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
@@ -37,6 +37,7 @@ export function ContactInfoElement({
const isMediaAvailable = element.imageUrl || element.videoUrl;
const formRef = useRef<HTMLFormElement>(null);
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const safeValue = useMemo(() => {
return Array.isArray(value) ? value : ["", "", "", "", ""];
}, [value]);
@@ -148,7 +149,7 @@ export function ContactInfoElement({
onChange={(e) => {
handleChange(field.id, e.currentTarget.value);
}}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
aria-label={field.label}
dir={!safeValue[index] ? dir : "auto"}
/>
@@ -61,13 +61,13 @@ 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
dir="auto"
type="button"
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
onClick={handleExternalButtonClick}
className="fb-text-heading focus:fb-ring-focus fb-flex fb-items-center fb-rounded-md fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
{getLocalizedValue(element.ctaButtonLabel, languageCode)}
@@ -86,6 +86,7 @@ export function DateElement({
const [errorMessage, setErrorMessage] = useState("");
const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(value ? new Date(value) : undefined);
const [hideInvalid, setHideInvalid] = useState(!selectedDate);
@@ -160,7 +161,7 @@ export function DateElement({
onClick={() => {
setDatePickerOpen(true);
}}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
type="button"
onKeyDown={(e) => {
if (e.key === " ") setDatePickerOpen(true);
@@ -31,6 +31,7 @@ export function MatrixElement({
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const rowShuffleIdx = useMemo(() => {
if (element.shuffleOption !== "none") {
return getShuffledRowIndices(element.rows.length, element.shuffleOption);
@@ -126,7 +127,7 @@ export function MatrixElement({
{element.columns.map((column, columnIndex) => (
<td
key={`column-${columnIndex.toString()}`}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
className={`fb-outline-brand fb-px-4 fb-py-2 fb-text-slate-800 ${columnIndex === element.columns.length - 1 ? "fb-rounded-r-custom" : ""}`}
onClick={() => {
handleSelect(
@@ -57,6 +57,7 @@ export function MultipleChoiceMultiElement({
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = element.imageUrl || element.videoUrl;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, element.id === currentElementId);
const isCurrent = element.id === currentElementId;
const shuffledChoicesIds = useMemo(() => {
if (element.shuffleOption) {
return getShuffledChoicesIds(element.choices, element.shuffleOption);
@@ -211,9 +212,9 @@ export function MultipleChoiceMultiElement({
return (
<label
key={choice.id}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(choice.id)} // NOSONAR - needed for keyboard navigation through options
onKeyDown={handleKeyDown(choice.id)}
autoFocus={idx === 0 && autoFocusEnabled}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
@@ -260,16 +261,14 @@ export function MultipleChoiceMultiElement({
return (
<label
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
// Disable keyboard navigation when 'other' option is selected to allow space key in input field
onKeyDown={otherSelected ? undefined : handleKeyDown(otherOption.id)} // NOSONAR - needed for keyboard navigation through options
>
onKeyDown={handleKeyDown(otherOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
dir={dir}
tabIndex={-1}
tabIndex={isCurrent ? 0 : -1}
id={otherOption.id}
name={element.id}
value={otherLabel}
@@ -290,7 +289,7 @@ export function MultipleChoiceMultiElement({
id={`${otherOption.id}-specify`}
maxLength={250}
name={element.id}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
value={otherValue}
pattern=".*\S+.*"
onChange={(e) => setOtherValue(e.currentTarget.value)}
@@ -315,10 +314,9 @@ export function MultipleChoiceMultiElement({
return (
<label
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(noneOption.id)} // NOSONAR - needed for keyboard navigation through options
>
onKeyDown={handleKeyDown(noneOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
@@ -36,6 +36,7 @@ export function MultipleChoiceSingleElement({
const otherSpecify = useRef<HTMLInputElement | null>(null);
const choicesContainerRef = useRef<HTMLDivElement | null>(null);
const isMediaAvailable = element.imageUrl || element.videoUrl;
const isCurrent = element.id === currentElementId;
const shuffledChoicesIds = useMemo(() => {
if (element.shuffleOption) {
return getShuffledChoicesIds(element.choices, element.shuffleOption);
@@ -157,9 +158,9 @@ export function MultipleChoiceSingleElement({
return (
<label
key={choice.id}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(choice.id)} // NOSONAR - needed for keyboard navigation through options
onKeyDown={handleKeyDown(choice.id)}
autoFocus={idx === 0 && autoFocusEnabled}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
@@ -196,11 +197,7 @@ export function MultipleChoiceSingleElement({
: "Please specify";
return (
<label
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
className={labelClassName}
onKeyDown={handleOtherKeyDown} // NOSONAR - needed for keyboard navigation through options
>
<label tabIndex={isCurrent ? 0 : -1} className={labelClassName} onKeyDown={handleOtherKeyDown}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
@@ -249,7 +246,7 @@ export function MultipleChoiceSingleElement({
return (
<label
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
tabIndex={isCurrent ? 0 : -1}
className={labelClassName}
onKeyDown={handleKeyDown(noneOption.id)}>
<span className="fb-flex fb-items-center fb-text-sm">
@@ -33,6 +33,7 @@ export function NPSElement({
const [startTime, setStartTime] = useState(performance.now());
const [hoveredNumber, setHoveredNumber] = useState(-1);
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) => {
@@ -73,7 +74,7 @@ export function NPSElement({
return (
<label
key={number}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
tabIndex={isCurrent ? 0 : -1}
onMouseOver={() => {
setHoveredNumber(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);
@@ -169,7 +171,7 @@ export function OpenTextElement({
<input
ref={inputRef as RefObject<HTMLInputElement>}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
name={element.id}
id={element.id}
placeholder={getLocalizedValue(element.placeholder, languageCode)}
@@ -195,7 +197,7 @@ export function OpenTextElement({
rows={3}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
name={element.id}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
aria-label="textarea"
id={element.id}
placeholder={getLocalizedValue(element.placeholder, languageCode, true)}
@@ -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()}
/>
@@ -148,7 +148,7 @@ export function PictureSelectionElement({
<div className="fb-relative" key={choice.id}>
<button
type="button"
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
onKeyDown={handleKeyDown}
onClick={() => handleChange(choice.id)}
className={getButtonClassName(choice.id)}>
@@ -159,7 +159,7 @@ export function RankingElement({
)}>
<button
autoFocus={idx === 0 && autoFocusEnabled}
tabIndex={0}
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();
@@ -46,14 +46,33 @@ export function RatingElement({
const [hoveredNumber, setHoveredNumber] = useState(0);
const [startTime, setStartTime] = useState(performance.now());
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) => {
onChange({ [element.id]: number });
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtcObj);
// Note: onSubmit prop is () => {} in multi-element blocks, called by block instead
};
function HiddenRadioInput({ number, id }: { number: number; id?: string }) {
return (
<input
type="radio"
id={id}
name="rating"
value={number}
className="fb-invisible fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => {
handleSelect(number);
}}
required={element.required}
checked={value === number}
/>
);
}
useEffect(() => {
setHoveredNumber(0);
}, [element.id, setHoveredNumber]);
@@ -79,6 +98,14 @@ export function RatingElement({
setTtc(updatedTtcObj);
};
const handleKeyDown = (number: number) => (e: KeyboardEvent) => {
if (e.key === " ") {
e.preventDefault();
document.getElementById(number.toString())?.click();
document.getElementById(number.toString())?.focus();
}
};
const handleMouseOver = (number: number) => () => {
setHoveredNumber(number);
};
@@ -134,21 +161,10 @@ export function RatingElement({
);
};
const getRatingInputId = (number: number) => `${element.id}-${number}`;
const handleKeyDown = (number: number) => (e: KeyboardEvent) => {
if (e.key === " ") {
e.preventDefault();
const inputId = getRatingInputId(number);
document.getElementById(inputId)?.click();
document.getElementById(inputId)?.focus();
}
};
const renderNumberScale = (number: number, totalLength: number) => {
return (
<label
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
tabIndex={isCurrent ? 0 : -1}
onKeyDown={handleKeyDown(number)}
className={getNumberLabelClassName(number, totalLength)}>
{element.isColorCodingEnabled && (
@@ -156,19 +172,7 @@ export function RatingElement({
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getRatingNumberOptionColor(element.range, number)}`}
/>
)}
<input
type="radio"
id={getRatingInputId(number)}
name="rating"
value={number}
className="fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => {
handleSelect(number);
}}
required={element.required}
checked={value === number}
tabIndex={-1}
/>
<HiddenRadioInput number={number} id={number.toString()} />
{number}
</label>
);
@@ -177,25 +181,12 @@ export function RatingElement({
const renderStarScale = (number: number) => {
return (
<label
aria-label={`Rate ${number} out of ${element.range}`}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
tabIndex={isCurrent ? 0 : -1}
onKeyDown={handleKeyDown(number)}
className={getStarLabelClassName(number)}
onFocus={handleFocus(number)}
onBlur={handleBlur}>
<input
type="radio"
id={getRatingInputId(number)}
name="rating"
value={number}
className="fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => {
handleSelect(number);
}}
required={element.required}
checked={value === number}
tabIndex={-1}
/>
<HiddenRadioInput number={number} id={number.toString()} />
<div className="fb-h-full fb-w-full fb-max-w-[74px] fb-object-contain">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
@@ -211,25 +202,12 @@ export function RatingElement({
const renderSmileyScale = (number: number, idx: number) => {
return (
<label
aria-label={`Rate ${number} out of ${element.range}`}
tabIndex={0} // NOSONAR - needed for keyboard navigation through options
tabIndex={isCurrent ? 0 : -1}
className={getSmileyLabelClassName(number)}
onKeyDown={handleKeyDown(number)}
onFocus={handleFocus(number)}
onBlur={handleBlur}>
<input
type="radio"
id={getRatingInputId(number)}
name="rating"
value={number}
className="fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => {
handleSelect(number);
}}
required={element.required}
checked={value === number}
tabIndex={-1}
/>
<HiddenRadioInput number={number} id={number.toString()} />
<div className="fb-h-full fb-w-full fb-max-w-[74px] fb-object-contain">
<RatingSmiley
active={value === number || hoveredNumber === number}
@@ -277,7 +255,7 @@ export function RatingElement({
renderRatingOption(number, i, a.length)
)}
</div>
<div className="fb-text-subheading fb-mt-8 fb-flex fb-justify-between fb-px-1.5 fb-text-xs fb-leading-6 fb-gap-8">
<div className="fb-text-subheading fb-mt-4 fb-flex fb-justify-between fb-px-1.5 fb-text-xs fb-leading-6 fb-gap-8">
<p className="fb-max-w-[50%]" dir="auto">
{getLocalizedValue(element.lowerLabel, languageCode)}
</p>
@@ -3,11 +3,7 @@ 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,9 +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>({});
// Handle change for an individual element
const handleElementChange = (elementId: string, responseData: TResponseData) => {
// If user moved to a different element, we should track it
@@ -76,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) {
@@ -112,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) {
@@ -236,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);
};
@@ -277,7 +225,6 @@ export function BlockConditional({
elementFormRefs.current.delete(element.id);
}
}}
onTtcCollect={handleTtcCollect}
/>
</div>
);
@@ -42,7 +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
}
export function ElementConditional({
@@ -61,7 +60,6 @@ export function ElementConditional({
onOpenExternalURL,
dir,
formRef,
onTtcCollect,
}: ElementConditionalProps) {
// Ref to the container div, used to find and expose the form element inside
const containerRef = useRef<HTMLDivElement>(null);
@@ -77,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) => {
@@ -124,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 (
@@ -135,7 +122,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
@@ -150,7 +137,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
@@ -165,7 +152,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
@@ -180,7 +167,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
@@ -195,7 +182,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
onOpenExternalURL={onOpenExternalURL}
@@ -210,7 +197,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
@@ -225,7 +212,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
@@ -240,7 +227,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
/>
@@ -254,7 +241,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
@@ -271,7 +258,7 @@ export function ElementConditional({
onFileUpload={onFileUpload}
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
/>
@@ -285,7 +272,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
setTtc={setTtc}
currentElementId={currentElementId}
/>
);
@@ -297,7 +284,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
setTtc={setTtc}
currentElementId={currentElementId}
/>
);
@@ -309,7 +296,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
setTtc={setTtc}
currentElementId={currentElementId}
autoFocusEnabled={autoFocusEnabled}
dir={dir}
@@ -323,7 +310,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={wrappedSetTtc}
setTtc={setTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
/>
@@ -336,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;
+1 -19
View File
@@ -13,7 +13,6 @@ import nlTranslations from "../../locales/nl.json";
import ptTranslations from "../../locales/pt.json";
import roTranslations from "../../locales/ro.json";
import ruTranslations from "../../locales/ru.json";
import svTranslations from "../../locales/sv.json";
import uzTranslations from "../../locales/uz.json";
import zhHansTranslations from "../../locales/zh-Hans.json";
@@ -22,23 +21,7 @@ i18n
.use(initReactI18next)
.init({
fallbackLng: "en",
supportedLngs: [
"en",
"de",
"it",
"fr",
"es",
"ar",
"pt",
"ro",
"ja",
"ru",
"uz",
"zh-Hans",
"hi",
"nl",
"sv",
],
supportedLngs: ["en", "de", "it", "fr", "es", "ar", "pt", "ro", "ja", "ru", "uz", "zh-Hans", "hi", "nl"],
resources: {
en: { translation: enTranslations },
@@ -53,7 +36,6 @@ i18n
nl: { translation: nlTranslations },
ru: { translation: ruTranslations },
uz: { translation: uzTranslations },
sv: { translation: svTranslations },
"zh-Hans": { translation: zhHansTranslations },
hi: { translation: hiTranslations },
},
+6 -1
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
import { ZColor, ZPlacement } from "./common";
import { ZEnvironment } from "./environment";
import { ZBaseStyling, ZLogo } from "./styling";
import { ZBaseStyling } from "./styling";
export const ZProjectStyling = ZBaseStyling.extend({
allowStyleOverwrite: z.boolean(),
@@ -46,6 +46,11 @@ export const ZLanguageUpdate = z.object({
});
export type TLanguageUpdate = z.infer<typeof ZLanguageUpdate>;
export const ZLogo = z.object({
url: z.string().optional(),
bgColor: z.string().optional(),
});
export type TLogo = z.infer<typeof ZLogo>;
export const ZProject = z.object({
-7
View File
@@ -15,12 +15,6 @@ export const ZCardArrangement = z.object({
appSurveys: ZCardArrangementOptions,
});
export const ZLogo = z.object({
url: z.string().optional(),
bgColor: z.string().optional(),
});
export type TLogo = z.infer<typeof ZLogo>;
export const ZSurveyStylingBackground = z
.object({
bg: z.string().nullish(),
@@ -54,7 +48,6 @@ export const ZBaseStyling = z.object({
background: ZSurveyStylingBackground.nullish(),
hideProgressBar: z.boolean().nullish(),
isLogoHidden: z.boolean().nullish(),
logo: ZLogo.nullish(),
});
export type TBaseStyling = z.infer<typeof ZBaseStyling>;
+2 -26
View File
@@ -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 {
+19 -11
View File
@@ -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;
+1 -1
View File
@@ -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)) {
-1
View File
@@ -12,7 +12,6 @@ export const ZUserLocale = z.enum([
"ja-JP",
"zh-Hans-CN",
"es-ES",
"sv-SE",
]);
export type TUserLocale = z.infer<typeof ZUserLocale>;
+591 -607
View File
File diff suppressed because it is too large Load Diff