Compare commits

..

70 Commits

Author SHA1 Message Date
Jakob Schott
74337df278 fixed some of sonarQube errors 2025-04-24 11:36:50 +02:00
victorvhs017
3f16291137 fix: updated encryption algorithm and added sort function (#5485)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-24 07:47:22 +00:00
victorvhs017
a5958d5653 chore: update sonar properties file (#5472) 2025-04-23 22:09:16 +02:00
victorvhs017
fdbdf8207a chore: add js-core to SonarQube check (#5466) 2025-04-23 22:08:31 +02:00
Piyush Gupta
630e5489ec feat: Implement v2 management api endpoint for contact attribute keys (#5316)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-23 15:48:18 +00:00
Anshuman Pandey
36943bb786 fix: android sdk callbacks, tweaks and fixes (#5487) 2025-04-23 13:37:22 +00:00
Dhruwang Jariwala
e1bbb0a10f fix: billing (#5483) 2025-04-23 09:54:08 +00:00
Piyush Jain
27da540846 chore: fix buckets and iam for staging env (#5475) 2025-04-23 08:24:45 +00:00
Piyush Jain
7d7f6ed04a chore(terraform): add valkey and rds for staging env (#5471) 2025-04-22 16:11:16 +00:00
Vijay
ff01bc342d fix: Some DoS (usage of regex) Sonar Security Hotspots (#5334) 2025-04-22 17:16:22 +02:00
Anshuman Pandey
cd8b40b569 fix: cleanup issue in iOS package (#5473) 2025-04-22 14:50:28 +00:00
Anshuman Pandey
31c742f7a8 fix: setLanguage and icon issue for the iOS SDK (#5470) 2025-04-22 13:18:28 +00:00
Dhruwang Jariwala
d6a7a2c21f fix: x button not visible when close on click outside is not allowed (#5464)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-22 07:13:04 +00:00
Dhruwang Jariwala
499ecab691 chore: update alpine version (#5465) 2025-04-22 06:48:52 +00:00
victorvhs017
df06540f1b chore: move package lib to web/lib (#5425) 2025-04-21 15:57:54 +02:00
victorvhs017
a32b213ca5 chore: Enable Sentry integration (#5337)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-21 12:41:54 +00:00
Piyush Gupta
6120f992a4 chore: updates prisma to latest version (#5395)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-21 13:51:39 +02:00
Matti Nannt
389a551a69 fix: docker build error because of deprecated Github cache (#5428) 2025-04-21 11:56:27 +02:00
Peter Pesti-Varga
8ddbdc0e1e fix: Address SonarQube code smells (#5416)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-21 11:30:45 +02:00
Piyush Gupta
302c6a90c0 chore: removes formbricks_encryption_key-environment-variable (#5426) 2025-04-21 05:55:48 +00:00
Anshuman Pandey
18e597d8a3 fix: smaller fixes and tweaks (#5417) 2025-04-18 12:26:21 +00:00
victorvhs017
81d717ccff fix: iOS SDK memory leaks (#5388)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-17 18:03:05 +00:00
Dhruwang Jariwala
2e979c7323 fix: managers should not be allowed to create api keys (#5409) 2025-04-17 14:12:55 +00:00
Anshuman Pandey
4dfd15d6dd fix: adds no-cache header when debug mode is ON (#5405) 2025-04-17 09:03:44 +00:00
Matti Nannt
5b9bf3ff43 chore(infra): increase cloudwatch elb alarm limit to 10 (#5407) 2025-04-17 06:41:13 +00:00
Anshuman Pandey
d2f7485098 feat: advanced follow ups (#5340)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-04-17 06:39:22 +00:00
Dhruwang Jariwala
f8fee1fba7 fix: refactor end screen card description ux (#5386)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-17 06:28:18 +00:00
Matti Nannt
19249ca00f chore: enable performance insights for rds (#5404) 2025-04-17 06:27:34 +00:00
Anshuman Pandey
01e5700340 fix: adds eslint rules for using test and refactors the current tests (#5397) 2025-04-17 03:32:03 +00:00
Johannes
ff2f7660a6 chore: add segment id to modal view (#5391)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-17 02:56:32 +00:00
Johannes
2bc05e2b4a docs: tweak quick start guides (#5403) 2025-04-16 19:38:54 -07:00
Johannes
137c6447b7 docs: tweak Data Prefilling docs for clarity (#5402) 2025-04-16 18:43:00 -07:00
Piyush Gupta
ebc8f0c917 fix: docker build test (#5396) 2025-04-16 12:32:38 +00:00
Anshuman Pandey
5a8d10b5b4 fix: removes the onFinished callbacks from the iOS package (#5384) 2025-04-16 11:11:30 +00:00
Anshuman Pandey
875815fb62 fix: fixes cb urls (#5392) 2025-04-16 04:47:14 +00:00
Dhruwang Jariwala
cdf526e130 fix: type issue in notion integration (#5385) 2025-04-16 04:15:27 +00:00
Dhruwang Jariwala
b685032b34 chore: make env permissions optional in api key (#5309)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-15 11:42:48 +00:00
Vijay
a171f9cb00 fix: security hotspots in Dockerfile (#5314) 2025-04-15 04:15:56 -07:00
Dhruwang Jariwala
c452f05ec2 feat: questionid to summary (#5381) 2025-04-15 05:58:10 +00:00
Dhruwang Jariwala
93d91f80f2 fix: progress bar calculation (#5339) 2025-04-15 00:51:47 +00:00
Piyush Gupta
7b764c8427 fix: adds api_key label to the view permission modal (#5326) 2025-04-14 08:53:14 +00:00
Piyush Gupta
016289c8cb fix: download responses button label (#5324)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-14 07:10:40 +00:00
Dhruwang Jariwala
93a9575389 fix: missing translation in api key modal (#5341) 2025-04-14 07:02:43 +00:00
victorvhs017
9e265adf14 chore: add test files to modules/account and modules/analysis (#5294)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-13 19:04:56 +00:00
Dhruwang Jariwala
eb08a0ed14 fix: buttonLabel conditions (#5336)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-13 08:36:59 +00:00
Dhruwang Jariwala
c533f37983 chore: improve accessibility for matrix question (#5320)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-04-12 04:40:24 +00:00
Anshuman Pandey
ca4f8385e4 fix: adds FormbricksEnvironment struct for url constants (#5312) 2025-04-11 13:44:25 +00:00
Matti Nannt
3eb9aa74ed chore: upgrade typescript and react dependencies (#5317) 2025-04-11 13:01:54 +02:00
Piyush Gupta
637b51464c docs: updates the API keys docs in API reference (#5319) 2025-04-11 08:46:04 +00:00
Dhruwang Jariwala
fd9585a66e fix: respondent should not see redirect card text (#5239)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-11 04:22:25 +00:00
Matti Nannt
49ecbcb0c9 fix: updatedAt not set in response update (#5315) 2025-04-10 11:04:42 +00:00
Piyush Gupta
1132bdd66a fix: openAPI spec for contact endpoints (#5247)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-10 10:22:40 +00:00
Anshuman Pandey
c7d6ed9ea3 chore: removes api package and deps (#5251)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-04-10 09:41:39 +00:00
Matti Nannt
782528f169 chore: update surveys package npm dependencies (#5302) 2025-04-10 10:44:56 +02:00
Piyush Gupta
104c78275f docs: fixes framework guide link (#5307) 2025-04-10 08:11:40 +00:00
Matti Nannt
d9d88f7175 chore: update eslint npm dependencies (#5313) 2025-04-10 10:22:58 +02:00
Dhruwang Jariwala
bf7e24cf11 fix: stripe issue for customers with existing stripe ID (#5308) 2025-04-10 07:56:01 +00:00
Anshuman Pandey
c8aba01db3 fix: adds isWebEnvironment check in the surveys package (#5310) 2025-04-10 09:01:36 +02:00
Piyush Gupta
a896c7e46e docs: updated API playground link in the webhooks docs (#5301) 2025-04-09 08:33:36 +00:00
Matti Nannt
8018ec14a2 chore: use remote turbocache for building formbricks (#5305) 2025-04-09 10:38:17 +02:00
victorvhs017
9c3208c860 chore: Refactored the Formbricks next public env variables and added test files (#5014)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-09 08:10:32 +00:00
Anshuman Pandey
e1063964cf fix: fixes segment self referencing issue (#5254)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-09 06:58:28 +00:00
victorvhs017
38568738cc feat: Added test configuration and initial test files to the surveys package (#5253)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-09 06:53:16 +00:00
Piyush Gupta
15b8358b14 fix: date format in response table (#5304) 2025-04-09 05:39:57 +00:00
Anshuman Pandey
2173cb2610 fix: removes sourcemaps (#5257) 2025-04-09 04:50:56 +00:00
Matti Nannt
87b925d622 chore: update apps/web npm dependencies (#5300) 2025-04-09 06:58:53 +02:00
Piyush Gupta
885b06cc26 fix: adds date value check in date question summary (#5296) 2025-04-09 04:07:39 +00:00
Matti Nannt
adb6a5f41e chore: upgrade npm dependencies (#5299) 2025-04-09 04:47:07 +02:00
Matti Nannt
3b815e22e3 chore: add docker build check github action (#4875)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-04-08 13:26:48 +00:00
Matti Nannt
4d4a5c0e64 fix: solve sonarqube security hotspots (#5292) 2025-04-08 14:58:24 +02:00
111 changed files with 6659 additions and 7030 deletions

View File

@@ -1,26 +0,0 @@
# Testing Instructions
When generating test files inside the "/app/web" path, follow these rules:
- Use vitest
- Ensure 100% code coverage
- Add as few comments as possible
- The test file should be located in the same folder as the original file
- Use the `test` function instead of `it`
- Follow the same test pattern used for other files in the package where the file is located
- All imports should be at the top of the file, not inside individual tests
- For mocking inside "test" blocks use "vi.mocked"
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
If it's a test for a ".tsx" file, follow these extra instructions:
- Add this code inside the "describe" block and before any test:
afterEach(() => {
cleanup();
});
- the "afterEach" function should only have "cleanup()" inside it and should be adde to the "vitest" imports
- For click events, import userEvent from "@testing-library/user-event"
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.

View File

@@ -1,4 +1,9 @@
{
"github.copilot.chat.codeGeneration.instructions": [
{
"text": "When generating tests, always use vitest and use the `test` function instead of `it`."
}
],
"javascript.updateImportsOnFileMove.enabled": "always",
"sonarlint.connectedMode.project": {
"connectionId": "formbricks",

View File

@@ -1,13 +1,8 @@
import {
buildCTAQuestion,
buildNPSQuestion,
buildOpenTextQuestion,
buildRatingQuestion,
getDefaultEndingCard,
} from "@/app/lib/survey-builder";
import { getDefaultEndingCard } from "@/app/lib/templates";
import { createId } from "@paralleldrive/cuid2";
import { TFnType } from "@tolgee/react";
import { logger } from "@formbricks/logger";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates";
export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
@@ -31,26 +26,35 @@ const npsSurvey = (t: TFnType): TXMTemplate => {
...getXMSurveyDefault(t),
name: t("templates.nps_survey_name"),
questions: [
buildNPSQuestion({
headline: t("templates.nps_survey_question_1_headline"),
{
id: createId(),
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: t("templates.nps_survey_question_1_headline") },
required: true,
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
upperLabel: t("templates.nps_survey_question_1_upper_label"),
lowerLabel: { default: t("templates.nps_survey_question_1_lower_label") },
upperLabel: { default: t("templates.nps_survey_question_1_upper_label") },
isColorCodingEnabled: true,
t,
}),
buildOpenTextQuestion({
headline: t("templates.nps_survey_question_2_headline"),
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.nps_survey_question_2_headline") },
required: false,
inputType: "text",
t,
}),
buildOpenTextQuestion({
headline: t("templates.nps_survey_question_3_headline"),
charLimit: {
enabled: false,
},
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.nps_survey_question_3_headline") },
required: false,
inputType: "text",
t,
}),
charLimit: {
enabled: false,
},
},
],
};
};
@@ -63,8 +67,9 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
...defaultSurvey,
name: t("templates.star_rating_survey_name"),
questions: [
buildRatingQuestion({
{
id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [
{
id: createId(),
@@ -97,15 +102,16 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
],
range: 5,
scale: "number",
headline: t("templates.star_rating_survey_question_1_headline"),
headline: { default: t("templates.star_rating_survey_question_1_headline") },
required: true,
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
t,
}),
buildCTAQuestion({
lowerLabel: { default: t("templates.star_rating_survey_question_1_lower_label") },
upperLabel: { default: t("templates.star_rating_survey_question_1_upper_label") },
isColorCodingEnabled: false,
},
{
id: reusableQuestionIds[1],
html: t("templates.star_rating_survey_question_2_html"),
html: { default: t("templates.star_rating_survey_question_2_html") },
type: TSurveyQuestionTypeEnum.CTA,
logic: [
{
id: createId(),
@@ -132,23 +138,25 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
],
},
],
headline: t("templates.star_rating_survey_question_2_headline"),
headline: { default: t("templates.star_rating_survey_question_2_headline") },
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
buttonLabel: { default: t("templates.star_rating_survey_question_2_button_label") },
buttonExternal: true,
t,
}),
buildOpenTextQuestion({
},
{
id: reusableQuestionIds[2],
headline: t("templates.star_rating_survey_question_3_headline"),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.star_rating_survey_question_3_headline") },
required: true,
subheader: t("templates.star_rating_survey_question_3_subheader"),
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
subheader: { default: t("templates.star_rating_survey_question_3_subheader") },
buttonLabel: { default: t("templates.star_rating_survey_question_3_button_label") },
placeholder: { default: t("templates.star_rating_survey_question_3_placeholder") },
inputType: "text",
t,
}),
charLimit: {
enabled: false,
},
},
],
};
};
@@ -161,8 +169,9 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
...defaultSurvey,
name: t("templates.csat_survey_name"),
questions: [
buildRatingQuestion({
{
id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [
{
id: createId(),
@@ -195,14 +204,15 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
],
range: 5,
scale: "smiley",
headline: t("templates.csat_survey_question_1_headline"),
headline: { default: t("templates.csat_survey_question_1_headline") },
required: true,
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
upperLabel: t("templates.csat_survey_question_1_upper_label"),
t,
}),
buildOpenTextQuestion({
lowerLabel: { default: t("templates.csat_survey_question_1_lower_label") },
upperLabel: { default: t("templates.csat_survey_question_1_upper_label") },
isColorCodingEnabled: false,
},
{
id: reusableQuestionIds[1],
type: TSurveyQuestionTypeEnum.OpenText,
logic: [
{
id: createId(),
@@ -229,20 +239,25 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
],
},
],
headline: t("templates.csat_survey_question_2_headline"),
headline: { default: t("templates.csat_survey_question_2_headline") },
required: false,
placeholder: t("templates.csat_survey_question_2_placeholder"),
placeholder: { default: t("templates.csat_survey_question_2_placeholder") },
inputType: "text",
t,
}),
buildOpenTextQuestion({
charLimit: {
enabled: false,
},
},
{
id: reusableQuestionIds[2],
headline: t("templates.csat_survey_question_3_headline"),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.csat_survey_question_3_headline") },
required: false,
placeholder: t("templates.csat_survey_question_3_placeholder"),
placeholder: { default: t("templates.csat_survey_question_3_placeholder") },
inputType: "text",
t,
}),
charLimit: {
enabled: false,
},
},
],
};
};
@@ -252,22 +267,28 @@ const cessSurvey = (t: TFnType): TXMTemplate => {
...getXMSurveyDefault(t),
name: t("templates.cess_survey_name"),
questions: [
buildRatingQuestion({
{
id: createId(),
type: TSurveyQuestionTypeEnum.Rating,
range: 5,
scale: "number",
headline: t("templates.cess_survey_question_1_headline"),
headline: { default: t("templates.cess_survey_question_1_headline") },
required: true,
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
upperLabel: t("templates.cess_survey_question_1_upper_label"),
t,
}),
buildOpenTextQuestion({
headline: t("templates.cess_survey_question_2_headline"),
lowerLabel: { default: t("templates.cess_survey_question_1_lower_label") },
upperLabel: { default: t("templates.cess_survey_question_1_upper_label") },
isColorCodingEnabled: false,
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.cess_survey_question_2_headline") },
required: true,
placeholder: t("templates.cess_survey_question_2_placeholder"),
placeholder: { default: t("templates.cess_survey_question_2_placeholder") },
inputType: "text",
t,
}),
charLimit: {
enabled: false,
},
},
],
};
};
@@ -280,8 +301,9 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
...defaultSurvey,
name: t("templates.smileys_survey_name"),
questions: [
buildRatingQuestion({
{
id: reusableQuestionIds[0],
type: TSurveyQuestionTypeEnum.Rating,
logic: [
{
id: createId(),
@@ -314,15 +336,16 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
],
range: 5,
scale: "smiley",
headline: t("templates.smileys_survey_question_1_headline"),
headline: { default: t("templates.smileys_survey_question_1_headline") },
required: true,
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
t,
}),
buildCTAQuestion({
lowerLabel: { default: t("templates.smileys_survey_question_1_lower_label") },
upperLabel: { default: t("templates.smileys_survey_question_1_upper_label") },
isColorCodingEnabled: false,
},
{
id: reusableQuestionIds[1],
html: t("templates.smileys_survey_question_2_html"),
html: { default: t("templates.smileys_survey_question_2_html") },
type: TSurveyQuestionTypeEnum.CTA,
logic: [
{
id: createId(),
@@ -349,23 +372,25 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
],
},
],
headline: t("templates.smileys_survey_question_2_headline"),
headline: { default: t("templates.smileys_survey_question_2_headline") },
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
buttonLabel: { default: t("templates.smileys_survey_question_2_button_label") },
buttonExternal: true,
t,
}),
buildOpenTextQuestion({
},
{
id: reusableQuestionIds[2],
headline: t("templates.smileys_survey_question_3_headline"),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.smileys_survey_question_3_headline") },
required: true,
subheader: t("templates.smileys_survey_question_3_subheader"),
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
placeholder: t("templates.smileys_survey_question_3_placeholder"),
subheader: { default: t("templates.smileys_survey_question_3_subheader") },
buttonLabel: { default: t("templates.smileys_survey_question_3_button_label") },
placeholder: { default: t("templates.smileys_survey_question_3_placeholder") },
inputType: "text",
t,
}),
charLimit: {
enabled: false,
},
},
],
};
};
@@ -375,26 +400,37 @@ const enpsSurvey = (t: TFnType): TXMTemplate => {
...getXMSurveyDefault(t),
name: t("templates.enps_survey_name"),
questions: [
buildNPSQuestion({
headline: t("templates.enps_survey_question_1_headline"),
{
id: createId(),
type: TSurveyQuestionTypeEnum.NPS,
headline: {
default: t("templates.enps_survey_question_1_headline"),
},
required: false,
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
upperLabel: t("templates.enps_survey_question_1_upper_label"),
lowerLabel: { default: t("templates.enps_survey_question_1_lower_label") },
upperLabel: { default: t("templates.enps_survey_question_1_upper_label") },
isColorCodingEnabled: true,
t,
}),
buildOpenTextQuestion({
headline: t("templates.enps_survey_question_2_headline"),
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.enps_survey_question_2_headline") },
required: false,
inputType: "text",
t,
}),
buildOpenTextQuestion({
headline: t("templates.enps_survey_question_3_headline"),
charLimit: {
enabled: false,
},
},
{
id: createId(),
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: t("templates.enps_survey_question_3_headline") },
required: false,
inputType: "text",
t,
}),
charLimit: {
enabled: false,
},
},
],
};
};

View File

@@ -1,151 +0,0 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationAirtable, TIntegrationAirtableConfig } from "@formbricks/types/integration/airtable";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal",
() => ({
AddIntegrationModal: ({ open, setOpenWithStates }) =>
open ? (
<div data-testid="add-modal">
<button onClick={() => setOpenWithStates(false)}>close</button>
</div>
) : null,
})
);
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete }) =>
open ? (
<div data-testid="delete-dialog">
<button onClick={onDelete}>confirm</button>
<button onClick={() => setOpen(false)}>cancel</button>
</div>
) : null,
}));
vi.mock("react-hot-toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const baseProps = {
environment: { id: "env1" } as TEnvironment,
environmentId: "env1",
setIsConnected: vi.fn(),
surveys: [],
airtableArray: [],
locale: "en-US" as const,
};
describe("ManageIntegration", () => {
afterEach(() => {
cleanup();
});
test("empty state", () => {
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument();
expect(screen.getByText(/link_new_table/)).toBeInTheDocument();
});
test("open add modal", async () => {
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
await userEvent.click(screen.getByText(/link_new_table/));
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
});
test("list integrations and open edit modal", async () => {
const item = {
baseId: "b",
tableId: "t",
surveyId: "s",
surveyName: "S",
tableName: "T",
questions: "Q",
questionIds: ["x"],
createdAt: new Date(),
includeVariables: false,
includeHiddenFields: false,
includeMetadata: false,
includeCreatedAt: false,
};
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [item] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
expect(screen.getByText("S")).toBeInTheDocument();
await userEvent.click(screen.getByText("S"));
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
});
test("delete integration success", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
await userEvent.click(screen.getByText("confirm"));
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
const { toast } = await import("react-hot-toast");
expect(toast.success).toHaveBeenCalled();
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
});
test("delete integration error", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
render(
<ManageIntegration
{...baseProps}
airtableIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
} as TIntegrationAirtable
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
await userEvent.click(screen.getByText("confirm"));
const { toast } = await import("react-hot-toast");
expect(toast.error).toHaveBeenCalled();
});
});

View File

@@ -98,17 +98,17 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
{integrationData.length ? (
<div className="mt-6 w-full rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
{tableHeaders.map((header) => (
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
{tableHeaders.map((header, idx) => (
<div key={idx} className={`col-span-2 hidden text-center sm:block`}>
{t(header)}
</div>
))}
</div>
{integrationData.map((data, index) => (
<button
key={`${index}-${data.baseId}-${data.tableId}-${data.surveyId}`}
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
<div
key={index}
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg hover:bg-slate-100"
onClick={() => {
setDefaultValues({
base: data.baseId,
@@ -129,7 +129,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)}
</div>
</button>
</div>
))}
</div>
) : (

View File

@@ -1,162 +0,0 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
open ? (
<div data-testid="delete-dialog">
<button onClick={onDelete}>confirm</button>
<button onClick={() => setOpen(false)}>cancel</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
EmptySpaceFiller: ({ emptyMessage }: any) => <div>{emptyMessage}</div>,
}));
const baseProps = {
environment: { id: "env1" } as TEnvironment,
setOpenAddIntegrationModal: vi.fn(),
setIsConnected: vi.fn(),
setSelectedIntegration: vi.fn(),
locale: "en-US" as const,
} as const;
describe("ManageIntegration (Google Sheets)", () => {
afterEach(() => {
cleanup();
});
test("empty state", () => {
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument();
expect(screen.getByText(/link_new_sheet/)).toBeInTheDocument();
});
test("click link new sheet", async () => {
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
await userEvent.click(screen.getByText(/link_new_sheet/));
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null);
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
});
test("list integrations and open edit", async () => {
const item = {
spreadsheetId: "sid",
spreadsheetName: "SheetName",
surveyId: "s1",
surveyName: "Survey1",
questionIds: ["q1"],
questions: "Q",
createdAt: new Date(),
};
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [item] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
expect(screen.getByText("Survey1")).toBeInTheDocument();
await userEvent.click(screen.getByText("Survey1"));
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({
...item,
index: 0,
});
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
});
test("delete integration success", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
await userEvent.click(screen.getByText("confirm"));
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
const { default: toast } = await import("react-hot-toast");
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
});
test("delete integration error", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
render(
<ManageIntegration
{...baseProps}
googleSheetIntegration={
{
id: "1",
config: { email: "a@b.com", data: [] },
} as unknown as TIntegrationGoogleSheets
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
await userEvent.click(screen.getByText("confirm"));
const { default: toast } = await import("react-hot-toast");
expect(toast.error).toHaveBeenCalledWith(expect.any(String));
});
});

View File

@@ -36,10 +36,11 @@ export const ManageIntegration = ({
}: ManageIntegrationProps) => {
const { t } = useTranslate();
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
let integrationArray: TIntegrationGoogleSheetsConfigData[] = [];
if (googleSheetIntegration?.config.data) {
integrationArray = googleSheetIntegration.config.data;
}
const integrationArray = googleSheetIntegration
? googleSheetIntegration.config.data
? googleSheetIntegration.config.data
: []
: [];
const [isDeleting, setisDeleting] = useState(false);
const handleDeleteIntegration = async () => {
@@ -111,9 +112,9 @@ export const ManageIntegration = ({
{integrationArray &&
integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.spreadsheetName}-${data.surveyName}`}
className="grid h-16 w-full cursor-pointer grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
<div
key={index}
className="m-2 grid h-16 cursor-pointer grid-cols-8 content-center rounded-lg hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
@@ -123,7 +124,7 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</button>
</div>
);
})}
</div>

View File

@@ -1,91 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import type {
TIntegrationNotion,
TIntegrationNotionConfig,
TIntegrationNotionConfigData,
TIntegrationNotionCredential,
} from "@formbricks/types/integration/notion";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("react-hot-toast", () => ({ success: vi.fn(), error: vi.fn() }));
vi.mock("@/lib/time", () => ({ timeSince: () => "ago" }));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
describe("ManageIntegration", () => {
afterEach(() => {
cleanup();
});
const defaultProps = {
environment: {} as any,
locale: "en-US" as const,
setOpenAddIntegrationModal: vi.fn(),
setIsConnected: vi.fn(),
setSelectedIntegration: vi.fn(),
handleNotionAuthorization: vi.fn(),
};
test("shows empty state when no databases", () => {
render(
<ManageIntegration
{...defaultProps}
notionIntegration={
{
id: "1",
config: {
data: [] as TIntegrationNotionConfigData[],
key: { workspace_name: "ws" } as TIntegrationNotionCredential,
} as TIntegrationNotionConfig,
} as TIntegrationNotion
}
/>
);
expect(screen.getByText("environments.integrations.notion.no_databases_found")).toBeInTheDocument();
});
test("renders list and handles clicks", async () => {
const data = [
{ surveyName: "S", databaseName: "D", createdAt: new Date().toISOString(), databaseId: "db" },
] as unknown as TIntegrationNotionConfigData[];
render(
<ManageIntegration
{...defaultProps}
notionIntegration={
{
id: "1",
config: { data, key: { workspace_name: "ws" } as TIntegrationNotionCredential },
} as TIntegrationNotion
}
/>
);
expect(screen.getByText("S")).toBeInTheDocument();
await userEvent.click(screen.getByText("S"));
expect(defaultProps.setSelectedIntegration).toHaveBeenCalledWith({ ...data[0], index: 0 });
expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled();
});
test("update and link new buttons invoke handlers", async () => {
render(
<ManageIntegration
{...defaultProps}
notionIntegration={
{
id: "1",
config: {
data: [],
key: { workspace_name: "ws" } as TIntegrationNotionCredential,
} as TIntegrationNotionConfig,
} as TIntegrationNotion
}
/>
);
await userEvent.click(screen.getByText("environments.integrations.notion.update_connection"));
expect(defaultProps.handleNotionAuthorization).toHaveBeenCalled();
await userEvent.click(screen.getByText("environments.integrations.notion.link_new_database"));
expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled();
});
});

View File

@@ -39,11 +39,11 @@ export const ManageIntegration = ({
const { t } = useTranslate();
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [isDeleting, setisDeleting] = useState(false);
let integrationArray: TIntegrationNotionConfigData[] = [];
if (notionIntegration?.config.data) {
integrationArray = notionIntegration.config.data;
}
const integrationArray = notionIntegration
? notionIntegration.config.data
? notionIntegration.config.data
: []
: [];
const handleDeleteIntegration = async () => {
setisDeleting(true);
@@ -121,9 +121,9 @@ export const ManageIntegration = ({
{integrationArray &&
integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.databaseId}`}
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
<div
key={index}
className="m-2 grid h-16 cursor-pointer grid-cols-6 content-center rounded-lg hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
@@ -132,7 +132,7 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</button>
</div>
);
})}
</div>

View File

@@ -1,158 +0,0 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({ default: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
open ? (
<div data-testid="delete-dialog">
<button onClick={onDelete}>confirm</button>
<button onClick={() => setOpen(false)}>cancel</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
EmptySpaceFiller: ({ emptyMessage }: any) => <div>{emptyMessage}</div>,
}));
const baseProps = {
environment: { id: "env1" } as TEnvironment,
setOpenAddIntegrationModal: vi.fn(),
setIsConnected: vi.fn(),
setSelectedIntegration: vi.fn(),
refreshChannels: vi.fn(),
handleSlackAuthorization: vi.fn(),
showReconnectButton: false,
locale: "en-US" as const,
};
describe("ManageIntegration (Slack)", () => {
afterEach(() => cleanup());
test("empty state", () => {
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
expect(screen.getByText(/connect_your_first_slack_channel/)).toBeInTheDocument();
expect(screen.getByText(/link_channel/)).toBeInTheDocument();
});
test("link channel triggers handlers", async () => {
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
await userEvent.click(screen.getByText(/link_channel/));
expect(baseProps.refreshChannels).toHaveBeenCalled();
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null);
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
});
test("show reconnect button and triggers authorization", async () => {
render(
<ManageIntegration
{...baseProps}
showReconnectButton={true}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "Team" } } },
} as unknown as TIntegrationSlack
}
/>
);
expect(screen.getByText("environments.integrations.slack.slack_reconnect_button")).toBeInTheDocument();
await userEvent.click(screen.getByText("environments.integrations.slack.slack_reconnect_button"));
expect(baseProps.handleSlackAuthorization).toHaveBeenCalled();
});
test("list integrations and open edit", async () => {
const item = {
surveyName: "S",
channelName: "C",
questions: "Q",
createdAt: new Date().toISOString(),
surveyId: "s",
channelId: "c",
} as unknown as TIntegrationSlackConfigData;
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [item], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
expect(screen.getByText("S")).toBeInTheDocument();
await userEvent.click(screen.getByText("S"));
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({ ...item, index: 0 });
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
});
test("delete integration success", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
await userEvent.click(screen.getByText("confirm"));
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
const { default: toast } = await import("react-hot-toast");
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
});
test("delete integration error", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
render(
<ManageIntegration
{...baseProps}
slackIntegration={
{
id: "1",
config: { data: [], key: { team: { name: "team name" } } },
} as unknown as TIntegrationSlack
}
/>
);
await userEvent.click(screen.getByText(/delete_integration/));
await userEvent.click(screen.getByText("confirm"));
const { default: toast } = await import("react-hot-toast");
expect(toast.error).toHaveBeenCalledWith(expect.any(String));
});
});

View File

@@ -6,7 +6,8 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { T, useTranslate } from "@tolgee/react";
import { useTranslate } from "@tolgee/react";
import { T } from "@tolgee/react";
import { Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
@@ -42,10 +43,11 @@ export const ManageIntegration = ({
const { t } = useTranslate();
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [isDeleting, setisDeleting] = useState(false);
let integrationArray: TIntegrationSlackConfigData[] = [];
if (slackIntegration?.config.data) {
integrationArray = slackIntegration.config.data;
}
const integrationArray = slackIntegration
? slackIntegration.config.data
? slackIntegration.config.data
: []
: [];
const handleDeleteIntegration = async () => {
setisDeleting(true);
@@ -127,9 +129,9 @@ export const ManageIntegration = ({
{integrationArray &&
integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.surveyName}-${data.channelName}`}
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 text-slate-700 hover:cursor-pointer hover:bg-slate-100"
<div
key={index}
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg text-slate-700 hover:cursor-pointer hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
@@ -139,7 +141,7 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</button>
</div>
);
})}
</div>

View File

@@ -1,165 +0,0 @@
import type { Cell, Row } from "@tanstack/react-table";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { ResponseTableCell } from "./ResponseTableCell";
const makeCell = (
id: string,
size = 100,
first = false,
last = false,
content = "CellContent"
): Cell<TResponseTableData, unknown> =>
({
column: {
id,
getSize: () => size,
getIsFirstColumn: () => first,
getIsLastColumn: () => last,
getStart: () => 0,
columnDef: { cell: () => content },
},
id,
getContext: () => ({}),
}) as unknown as Cell<TResponseTableData, unknown>;
const makeRow = (id: string, selected = false): Row<TResponseTableData> =>
({ id, getIsSelected: () => selected }) as unknown as Row<TResponseTableData>;
describe("ResponseTableCell", () => {
afterEach(() => {
cleanup();
});
test("renders cell content", () => {
const cell = makeCell("col1");
const row = makeRow("r1");
render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={false}
setSelectedResponseId={vi.fn()}
responses={[]}
/>
);
expect(screen.getByText("CellContent")).toBeDefined();
});
test("calls setSelectedResponseId on cell click when not select column", async () => {
const cell = makeCell("col1");
const row = makeRow("r1");
const setSel = vi.fn();
render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={false}
setSelectedResponseId={setSel}
responses={[{ id: "r1" } as TResponse]}
/>
);
await userEvent.click(screen.getByText("CellContent"));
expect(setSel).toHaveBeenCalledWith("r1");
});
test("does not call setSelectedResponseId on select column click", async () => {
const cell = makeCell("select");
const row = makeRow("r1");
const setSel = vi.fn();
render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={false}
setSelectedResponseId={setSel}
responses={[{ id: "r1" } as TResponse]}
/>
);
await userEvent.click(screen.getByText("CellContent"));
expect(setSel).not.toHaveBeenCalled();
});
test("renders maximize icon for createdAt column and handles click", async () => {
const cell = makeCell("createdAt", 120, false, false);
const row = makeRow("r2");
const setSel = vi.fn();
render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={false}
setSelectedResponseId={setSel}
responses={[{ id: "r2" } as TResponse]}
/>
);
const btn = screen.getByRole("button", { name: /expand response/i });
expect(btn).toBeDefined();
await userEvent.click(btn);
expect(setSel).toHaveBeenCalledWith("r2");
});
test("does not apply selected style when row.getIsSelected() is false", () => {
const cell = makeCell("col1");
const row = makeRow("r1", false);
const { container } = render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={false}
setSelectedResponseId={vi.fn()}
responses={[]}
/>
);
expect(container.firstChild).not.toHaveClass("bg-slate-100");
});
test("applies selected style when row.getIsSelected() is true", () => {
const cell = makeCell("col1");
const row = makeRow("r1", true);
const { container } = render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={false}
setSelectedResponseId={vi.fn()}
responses={[]}
/>
);
expect(container.firstChild).toHaveClass("bg-slate-100");
});
test("renders collapsed height class when isExpanded is false", () => {
const cell = makeCell("col1");
const row = makeRow("r1");
const { container } = render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={false}
setSelectedResponseId={vi.fn()}
responses={[]}
/>
);
const inner = container.querySelector("div > div");
expect(inner).toHaveClass("h-10");
});
test("renders expanded height class when isExpanded is true", () => {
const cell = makeCell("col1");
const row = makeRow("r1");
const { container } = render(
<ResponseTableCell
cell={cell}
row={row}
isExpanded={true}
setSelectedResponseId={vi.fn()}
responses={[]}
/>
);
const inner = container.querySelector("div > div");
expect(inner).toHaveClass("h-full");
});
});

View File

@@ -35,13 +35,11 @@ export const ResponseTableCell = ({
// Conditional rendering of maximize icon
const renderMaximizeIcon = cell.column.id === "createdAt" && (
<button
type="button"
aria-label="Expand response"
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300 focus:outline-none"
<div
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300"
onClick={handleCellClick}>
<Maximize2Icon className="h-4 w-4" />
</button>
</div>
);
return (

View File

@@ -1,80 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
TSurvey,
TSurveyConsentQuestion,
TSurveyQuestionSummaryConsent,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { ConsentSummary } from "./ConsentSummary";
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader",
() => ({
QuestionSummaryHeader: () => <div>QuestionSummaryHeader</div>,
})
);
describe("ConsentSummary", () => {
afterEach(() => {
cleanup();
});
const mockSetFilter = vi.fn();
const questionSummary = {
question: {
id: "q1",
headline: { en: "Headline" },
type: TSurveyQuestionTypeEnum.Consent,
} as unknown as TSurveyConsentQuestion,
accepted: { percentage: 60.5, count: 61 },
dismissed: { percentage: 39.5, count: 40 },
} as unknown as TSurveyQuestionSummaryConsent;
const survey = {} as TSurvey;
test("renders accepted and dismissed with correct values", () => {
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
expect(screen.getByText("common.accepted")).toBeInTheDocument();
expect(screen.getByText(/60\.5%/)).toBeInTheDocument();
expect(screen.getByText(/61/)).toBeInTheDocument();
expect(screen.getByText("common.dismissed")).toBeInTheDocument();
expect(screen.getByText(/39\.5%/)).toBeInTheDocument();
expect(screen.getByText(/40/)).toBeInTheDocument();
});
test("calls setFilter with correct args on accepted click", async () => {
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
await userEvent.click(screen.getByText("common.accepted"));
expect(mockSetFilter).toHaveBeenCalledWith(
"q1",
{ en: "Headline" },
TSurveyQuestionTypeEnum.Consent,
"is",
"common.accepted"
);
});
test("calls setFilter with correct args on dismissed click", async () => {
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
await userEvent.click(screen.getByText("common.dismissed"));
expect(mockSetFilter).toHaveBeenCalledWith(
"q1",
{ en: "Headline" },
TSurveyQuestionTypeEnum.Consent,
"is",
"common.dismissed"
);
});
test("renders singular and plural response labels", () => {
const oneAndTwo = {
...questionSummary,
accepted: { percentage: questionSummary.accepted.percentage, count: 1 },
dismissed: { percentage: questionSummary.dismissed.percentage, count: 2 },
};
render(<ConsentSummary questionSummary={oneAndTwo} survey={survey} setFilter={mockSetFilter} />);
expect(screen.getByText(/1 common\.response/)).toBeInTheDocument();
expect(screen.getByText(/2 common\.responses/)).toBeInTheDocument();
});
});

View File

@@ -41,11 +41,11 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{summaryItems.map((summaryItem) => {
return (
<button
className="group w-full cursor-pointer"
<div
className="group cursor-pointer"
key={summaryItem.title}
onClick={() =>
setFilter(
@@ -74,7 +74,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={summaryItem.percentage / 100} />
</div>
</button>
</div>
);
})}
</div>

View File

@@ -1,47 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { MatrixQuestionSummary } from "./MatrixQuestionSummary";
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader",
() => ({
QuestionSummaryHeader: () => <div>QuestionSummaryHeader</div>,
})
);
describe("MatrixQuestionSummary", () => {
afterEach(() => {
cleanup();
});
const survey = { id: "s1" } as any;
const questionSummary = {
question: { id: "q1", headline: "Q Head", type: "matrix" },
data: [
{
rowLabel: "Row1",
totalResponsesForRow: 10,
columnPercentages: [
{ column: "Yes", percentage: 50 },
{ column: "No", percentage: 50 },
],
},
],
} as any;
test("renders headers and buttons, click triggers setFilter", async () => {
const setFilter = vi.fn();
render(<MatrixQuestionSummary questionSummary={questionSummary} survey={survey} setFilter={setFilter} />);
// column headers
expect(screen.getByText("Yes")).toBeInTheDocument();
expect(screen.getByText("No")).toBeInTheDocument();
// row label
expect(screen.getByText("Row1")).toBeInTheDocument();
// buttons
const btn = screen.getAllByRole("button", { name: /50/ });
await userEvent.click(btn[0]);
expect(setFilter).toHaveBeenCalledWith("q1", "Q Head", "matrix", "Row1", "Yes");
});
});

View File

@@ -52,7 +52,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<table className="mx-auto border-collapse cursor-default text-left">
<thead>
<tr>
<th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
<th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
{columns.map((column) => (
<th key={column} className="text-center font-medium">
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<tbody>
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
<tr key={rowLabel}>
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap">
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
</TooltipRenderer>
@@ -81,7 +81,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
percentage,
questionSummary.data[rowIndex].totalResponsesForRow
)}>
<button
<div
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
onClick={() =>
@@ -94,7 +94,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
)
}>
{percentage}
</button>
</div>
</TooltipRenderer>
</td>
))}

View File

@@ -1,275 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { MultipleChoiceSummary } from "./MultipleChoiceSummary";
vi.mock("@/modules/ui/components/avatars", () => ({
PersonAvatar: ({ personId }: any) => <div data-testid="avatar">{personId}</div>,
}));
vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () => <div data-testid="header" /> }));
describe("MultipleChoiceSummary", () => {
afterEach(() => {
cleanup();
});
const baseSurvey = { id: "s1" } as any;
const envId = "env";
test("renders header and choice button", async () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q",
headline: "H",
type: "multipleChoiceSingle",
choices: [{ id: "c", label: { default: "C" } }],
},
choices: { C: { value: "C", count: 1, percentage: 100, others: [] } },
type: "multipleChoiceSingle",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId={envId}
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
expect(screen.getByTestId("header")).toBeDefined();
const btn = screen.getByText("1 - C");
await userEvent.click(btn);
expect(setFilter).toHaveBeenCalledWith(
"q",
"H",
"multipleChoiceSingle",
"environments.surveys.summary.includes_either",
["C"]
);
});
test("renders others and load more for link", async () => {
const setFilter = vi.fn();
const others = Array.from({ length: 12 }, (_, i) => ({
value: `O${i}`,
contact: { id: `id${i}` },
contactAttributes: {},
}));
const q = {
question: {
id: "q2",
headline: "H2",
type: "multipleChoiceMulti",
choices: [{ id: "c2", label: { default: "X" } }],
},
choices: { X: { value: "X", count: 0, percentage: 0, others } },
type: "multipleChoiceMulti",
selectionCount: 5,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId={envId}
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeDefined();
expect(screen.getAllByText(/^O/)).toHaveLength(10);
await userEvent.click(screen.getByText("common.load_more"));
expect(screen.getAllByText(/^O/)).toHaveLength(12);
});
test("renders others with avatar for app", () => {
const setFilter = vi.fn();
const others = [{ value: "Val", contact: { id: "uid" }, contactAttributes: {} }];
const q = {
question: {
id: "q3",
headline: "H3",
type: "multipleChoiceMulti",
choices: [{ id: "c3", label: { default: "L" } }],
},
choices: { L: { value: "L", count: 0, percentage: 0, others } },
type: "multipleChoiceMulti",
selectionCount: 1,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId={envId}
surveyType="app"
survey={baseSurvey}
setFilter={setFilter}
/>
);
expect(screen.getByTestId("avatar")).toBeDefined();
expect(screen.getByText("Val")).toBeDefined();
});
test("places choice without others before one with others", () => {
const setFilter = vi.fn();
const choices = {
A: { value: "A", count: 0, percentage: 0, others: [] },
B: { value: "B", count: 0, percentage: 0, others: [{ value: "x" }] },
};
render(
<MultipleChoiceSummary
questionSummary={
{
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
choices,
type: "multipleChoiceSingle",
selectionCount: 0,
} as any
}
environmentId="e"
surveyType="link"
survey={{} as any}
setFilter={setFilter}
/>
);
const btns = screen.getAllByRole("button");
expect(btns[0]).toHaveTextContent("2 - A");
expect(btns[1]).toHaveTextContent("1 - B");
});
test("sorts by count when neither has others", () => {
const setFilter = vi.fn();
const choices = {
X: { value: "X", count: 1, percentage: 50, others: [] },
Y: { value: "Y", count: 2, percentage: 50, others: [] },
};
render(
<MultipleChoiceSummary
questionSummary={
{
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
choices,
type: "multipleChoiceSingle",
selectionCount: 0,
} as any
}
environmentId="e"
surveyType="link"
survey={{} as any}
setFilter={setFilter}
/>
);
const btns = screen.getAllByRole("button");
expect(btns[0]).toHaveTextContent("2 - Y50%2 common.selections");
expect(btns[1]).toHaveTextContent("1 - X50%1 common.selection");
});
test("places choice with others after one without when reversed inputs", () => {
const setFilter = vi.fn();
const choices = {
C: { value: "C", count: 1, percentage: 0, others: [{ value: "z" }] },
D: { value: "D", count: 1, percentage: 0, others: [] },
};
render(
<MultipleChoiceSummary
questionSummary={
{
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
choices,
type: "multipleChoiceSingle",
selectionCount: 0,
} as any
}
environmentId="e"
surveyType="link"
survey={{} as any}
setFilter={setFilter}
/>
);
const btns = screen.getAllByRole("button");
expect(btns[0]).toHaveTextContent("2 - D");
expect(btns[1]).toHaveTextContent("1 - C");
});
test("multi type non-other uses includes_all", async () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q4",
headline: "H4",
type: "multipleChoiceMulti",
choices: [
{ id: "other", label: { default: "O" } },
{ id: "c4", label: { default: "C4" } },
],
},
choices: {
O: { value: "O", count: 1, percentage: 10, others: [] },
C4: { value: "C4", count: 2, percentage: 20, others: [] },
},
type: "multipleChoiceMulti",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId={envId}
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const btn = screen.getByText("2 - C4");
await userEvent.click(btn);
expect(setFilter).toHaveBeenCalledWith(
"q4",
"H4",
"multipleChoiceMulti",
"environments.surveys.summary.includes_all",
["C4"]
);
});
test("multi type other uses includes_either", async () => {
const setFilter = vi.fn();
const q = {
question: {
id: "q5",
headline: "H5",
type: "multipleChoiceMulti",
choices: [
{ id: "other", label: { default: "O5" } },
{ id: "c5", label: { default: "C5" } },
],
},
choices: {
O5: { value: "O5", count: 1, percentage: 10, others: [] },
C5: { value: "C5", count: 0, percentage: 0, others: [] },
},
type: "multipleChoiceMulti",
selectionCount: 0,
} as any;
render(
<MultipleChoiceSummary
questionSummary={q}
environmentId={envId}
surveyType="link"
survey={baseSurvey}
setFilter={setFilter}
/>
);
const btn = screen.getByText("2 - O5");
await userEvent.click(btn);
expect(setFilter).toHaveBeenCalledWith(
"q5",
"H5",
"multipleChoiceMulti",
"environments.surveys.summary.includes_either",
["O5"]
);
});
});

View File

@@ -7,7 +7,7 @@ import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
import Link from "next/link";
import { Fragment, useState } from "react";
import { useState } from "react";
import {
TI18nString,
TSurvey,
@@ -45,15 +45,10 @@ export const MultipleChoiceSummary = ({
const otherValue = questionSummary.question.choices.find((choice) => choice.id === "other")?.label.default;
// sort by count and transform to array
const results = Object.values(questionSummary.choices).sort((a, b) => {
const aHasOthers = (a.others?.length ?? 0) > 0;
const bHasOthers = (b.others?.length ?? 0) > 0;
if (a.others) return 1; // Always put a after b if a has 'others'
if (b.others) return -1; // Always put b after a if b has 'others'
// if one has “others” and the other doesnt, push the one with others to the end
if (aHasOthers && !bHasOthers) return 1;
if (!aHasOthers && bHasOthers) return -1;
// if theyre “tied” on having others, fall back to count
return b.count - a.count;
return b.count - a.count; // Sort by count
});
const handleLoadMore = (e: React.MouseEvent) => {
@@ -85,41 +80,40 @@ export const MultipleChoiceSummary = ({
/>
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<Fragment key={result.value}>
<button
className="group w-full cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
<div
key={result.value}
className="group cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{results.length - resultsIdx} - {result.value}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
</button>
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="mt-4 rounded-lg border border-slate-200" onClick={(e) => e.stopPropagation()}>
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
@@ -130,9 +124,11 @@ export const MultipleChoiceSummary = ({
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={`${idx}-${otherValue}`} dir="auto">
<div key={idx} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<div
key={idx}
className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
)}
@@ -143,6 +139,7 @@ export const MultipleChoiceSummary = ({
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
key={idx}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span>
@@ -166,7 +163,7 @@ export const MultipleChoiceSummary = ({
)}
</div>
)}
</Fragment>
</div>
))}
</div>
</div>

View File

@@ -1,60 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurveyQuestionSummaryNps } from "@formbricks/types/surveys/types";
import { NPSSummary } from "./NPSSummary";
vi.mock("@/modules/ui/components/progress-bar", () => ({
ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => (
<div data-testid="progress-bar">{`${progress}-${barColor}`}</div>
),
HalfCircle: ({ value }: { value: number }) => <div data-testid="half-circle">{value}</div>,
}));
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
}));
describe("NPSSummary", () => {
afterEach(() => {
cleanup();
});
const baseQuestion = { id: "q1", headline: "Question?", type: "nps" as const };
const summary = {
question: baseQuestion,
promoters: { count: 2, percentage: 50 },
passives: { count: 1, percentage: 25 },
detractors: { count: 1, percentage: 25 },
dismissed: { count: 0, percentage: 0 },
score: 25,
} as unknown as TSurveyQuestionSummaryNps;
const survey = {} as any;
test("renders header, groups, ProgressBar and HalfCircle", () => {
render(<NPSSummary questionSummary={summary} survey={survey} setFilter={() => {}} />);
expect(screen.getByTestId("question-summary-header")).toBeDefined();
["promoters", "passives", "detractors", "dismissed"].forEach((g) =>
expect(screen.getByText(g)).toBeDefined()
);
expect(screen.getAllByTestId("progress-bar")[0]).toBeDefined();
expect(screen.getByTestId("half-circle")).toHaveTextContent("25");
});
test.each([
["promoters", "environments.surveys.summary.includes_either", ["9", "10"]],
["passives", "environments.surveys.summary.includes_either", ["7", "8"]],
["detractors", "environments.surveys.summary.is_less_than", "7"],
["dismissed", "common.skipped", undefined],
])("clicking %s calls setFilter correctly", async (group, cmp, vals) => {
const setFilter = vi.fn();
render(<NPSSummary questionSummary={summary} survey={survey} setFilter={setFilter} />);
await userEvent.click(screen.getByText(group));
expect(setFilter).toHaveBeenCalledWith(
baseQuestion.id,
baseQuestion.headline,
baseQuestion.type,
cmp,
vals
);
});
});

View File

@@ -62,17 +62,14 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div className="cursor-pointer hover:opacity-80" key={group} onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}>
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
@@ -90,11 +87,11 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
</div>
))}
</div>
<div className="flex justify-center pt-4 pb-4">
<div className="flex justify-center pb-4 pt-4">
<HalfCircle value={questionSummary.score} />
</div>
</div>

View File

@@ -1,91 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { PictureChoiceSummary } from "./PictureChoiceSummary";
vi.mock("@/modules/ui/components/progress-bar", () => ({
ProgressBar: ({ progress }: { progress: number }) => (
<div data-testid="progress-bar" data-progress={progress} />
),
}));
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: ({ additionalInfo }: any) => <div data-testid="header">{additionalInfo}</div>,
}));
// mock next image
vi.mock("next/image", () => ({
__esModule: true,
// eslint-disable-next-line @next/next/no-img-element
default: ({ src }: { src: string }) => <img src={src} alt="" />,
}));
const survey = {} as TSurvey;
describe("PictureChoiceSummary", () => {
afterEach(() => {
cleanup();
});
test("renders choices with formatted percentages and counts", () => {
const choices = [
{ id: "1", imageUrl: "img1.png", percentage: 33.3333, count: 1 },
{ id: "2", imageUrl: "img2.png", percentage: 66.6667, count: 2 },
];
const questionSummary = {
choices,
question: { id: "q1", type: TSurveyQuestionTypeEnum.PictureSelection, headline: "H", allowMulti: true },
selectionCount: 3,
} as any;
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
expect(screen.getAllByRole("button")).toHaveLength(2);
expect(screen.getByText("33.33%")).toBeInTheDocument();
expect(screen.getByText("1 common.selection")).toBeInTheDocument();
expect(screen.getByText("2 common.selections")).toBeInTheDocument();
expect(screen.getAllByTestId("progress-bar")).toHaveLength(2);
});
test("calls setFilter with correct args on click", async () => {
const choices = [{ id: "1", imageUrl: "img1.png", percentage: 25, count: 10 }];
const questionSummary = {
choices,
question: {
id: "q1",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: "H1",
allowMulti: true,
},
selectionCount: 10,
} as any;
const setFilter = vi.fn();
const user = userEvent.setup();
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={setFilter} />);
await user.click(screen.getByRole("button"));
expect(setFilter).toHaveBeenCalledWith(
"q1",
"H1",
TSurveyQuestionTypeEnum.PictureSelection,
"environments.surveys.summary.includes_all",
["environments.surveys.edit.picture_idx"]
);
});
test("hides additionalInfo when allowMulti is false", () => {
const choices = [{ id: "1", imageUrl: "img1.png", percentage: 50, count: 5 }];
const questionSummary = {
choices,
question: {
id: "q1",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: "H2",
allowMulti: false,
},
selectionCount: 5,
} as any;
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
expect(screen.getByTestId("header")).toBeEmptyDOMElement();
});
});

View File

@@ -43,10 +43,10 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
) : undefined
}
/>
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, index) => (
<button
className="w-full cursor-pointer hover:opacity-80"
<div
className="cursor-pointer hover:opacity-80"
key={result.id}
onClick={() =>
setFilter(
@@ -79,7 +79,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
</button>
</div>
))}
</div>
</div>

View File

@@ -1,87 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurveyQuestionSummaryRating } from "@formbricks/types/surveys/types";
import { RatingSummary } from "./RatingSummary";
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: ({ additionalInfo }: any) => <div data-testid="header">{additionalInfo}</div>,
}));
describe("RatingSummary", () => {
afterEach(() => {
cleanup();
});
test("renders overall average and choices", () => {
const questionSummary = {
question: {
id: "q1",
scale: "star",
headline: "Headline",
type: "rating",
range: [1, 5],
isColorCodingEnabled: false,
},
average: 3.1415,
choices: [
{ rating: 1, percentage: 50, count: 2 },
{ rating: 2, percentage: 50, count: 3 },
],
dismissed: { count: 0 },
} as unknown as TSurveyQuestionSummaryRating;
const survey = {};
const setFilter = vi.fn();
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
expect(screen.getByText("environments.surveys.summary.overall: 3.14")).toBeDefined();
expect(screen.getAllByRole("button")).toHaveLength(2);
});
test("clicking a choice calls setFilter with correct args", async () => {
const questionSummary = {
question: {
id: "q1",
scale: "number",
headline: "Headline",
type: "rating",
range: [1, 5],
isColorCodingEnabled: false,
},
average: 2,
choices: [{ rating: 3, percentage: 100, count: 1 }],
dismissed: { count: 0 },
} as unknown as TSurveyQuestionSummaryRating;
const survey = {};
const setFilter = vi.fn();
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
await userEvent.click(screen.getByRole("button"));
expect(setFilter).toHaveBeenCalledWith(
"q1",
"Headline",
"rating",
"environments.surveys.summary.is_equal_to",
"3"
);
});
test("renders dismissed section when dismissed count > 0", () => {
const questionSummary = {
question: {
id: "q1",
scale: "smiley",
headline: "Headline",
type: "rating",
range: [1, 5],
isColorCodingEnabled: false,
},
average: 4,
choices: [],
dismissed: { count: 1 },
} as unknown as TSurveyQuestionSummaryRating;
const survey = {};
const setFilter = vi.fn();
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
expect(screen.getByText("common.dismissed")).toBeDefined();
expect(screen.getByText("1 common.response")).toBeDefined();
});
});

View File

@@ -50,10 +50,10 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
</div>
}
/>
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => (
<button
className="w-full cursor-pointer hover:opacity-80"
<div
className="cursor-pointer hover:opacity-80"
key={result.rating}
onClick={() =>
setFilter(
@@ -85,7 +85,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
</div>
))}
</div>
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (

View File

@@ -1,135 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { SummaryMetadata } from "./SummaryMetadata";
vi.mock("lucide-react", () => ({
ChevronDownIcon: () => <div data-testid="down" />,
ChevronUpIcon: () => <div data-testid="up" />,
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipProvider: ({ children }) => <>{children}</>,
Tooltip: ({ children }) => <>{children}</>,
TooltipTrigger: ({ children }) => <>{children}</>,
TooltipContent: ({ children }) => <>{children}</>,
}));
const baseSummary = {
completedPercentage: 50,
completedResponses: 2,
displayCount: 3,
dropOffPercentage: 25,
dropOffCount: 1,
startsPercentage: 75,
totalResponses: 4,
ttcAverage: 65000,
};
describe("SummaryMetadata", () => {
afterEach(() => {
cleanup();
});
test("renders loading skeletons when isLoading=true", () => {
const { container } = render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
surveySummary={baseSummary}
isLoading={true}
/>
);
expect(container.getElementsByClassName("animate-pulse")).toHaveLength(5);
});
test("renders all stats and formats time correctly, toggles dropOffs icon", async () => {
const Wrapper = () => {
const [show, setShow] = useState(false);
return (
<SummaryMetadata
showDropOffs={show}
setShowDropOffs={setShow}
surveySummary={baseSummary}
isLoading={false}
/>
);
};
render(<Wrapper />);
// impressions, starts, completed, drop_offs, ttc
expect(screen.getByText("environments.surveys.summary.impressions")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByText("75%")).toBeInTheDocument();
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("50%")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("25%")).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("1m 5.00s")).toBeInTheDocument();
const btn = screen.getByRole("button");
expect(screen.queryByTestId("down")).toBeInTheDocument();
await userEvent.click(btn);
expect(screen.queryByTestId("up")).toBeInTheDocument();
});
test("formats time correctly when < 60 seconds", () => {
const smallSummary = { ...baseSummary, ttcAverage: 5000 };
render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
surveySummary={smallSummary}
isLoading={false}
/>
);
expect(screen.getByText("5.00s")).toBeInTheDocument();
});
test("renders '-' for dropOffCount=0 and still toggles icon", async () => {
const zeroSummary = { ...baseSummary, dropOffCount: 0 };
const Wrapper = () => {
const [show, setShow] = useState(false);
return (
<SummaryMetadata
showDropOffs={show}
setShowDropOffs={setShow}
surveySummary={zeroSummary}
isLoading={false}
/>
);
};
render(<Wrapper />);
expect(screen.getAllByText("-")).toHaveLength(1);
const btn = screen.getByRole("button");
expect(screen.queryByTestId("down")).toBeInTheDocument();
await userEvent.click(btn);
expect(screen.queryByTestId("up")).toBeInTheDocument();
});
test("renders '-' for displayCount=0", () => {
const dispZero = { ...baseSummary, displayCount: 0 };
render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
surveySummary={dispZero}
isLoading={false}
/>
);
expect(screen.getAllByText("-")).toHaveLength(1);
});
test("renders '-' for totalResponses=0", () => {
const totZero = { ...baseSummary, totalResponses: 0 };
render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
surveySummary={totZero}
isLoading={false}
/>
);
expect(screen.getAllByText("-")).toHaveLength(1);
});
});

View File

@@ -71,8 +71,6 @@ export const SummaryMetadata = ({
ttcAverage,
} = surveySummary;
const { t } = useTranslate();
const displayCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
return (
<div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-5 md:gap-x-2 lg:col-span-4">
@@ -101,7 +99,9 @@ export const SummaryMetadata = ({
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
<div
onClick={() => setShowDropOffs(!showDropOffs)}
className="group flex h-full w-full cursor-pointer flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
<span className="text-sm text-slate-600">
{t("environments.surveys.summary.drop_offs")}
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
@@ -112,20 +112,20 @@ export const SummaryMetadata = ({
<span className="text-2xl font-bold text-slate-800">
{isLoading ? (
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
) : dropOffCount === 0 ? (
<span>-</span>
) : (
displayCountValue
dropOffCount
)}
</span>
{!isLoading && (
<button
className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700"
onClick={() => setShowDropOffs(!showDropOffs)}>
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</button>
</span>
)}
</div>
</div>
@@ -135,7 +135,6 @@ export const SummaryMetadata = ({
</TooltipContent>
</Tooltip>
</TooltipProvider>
<StatCard
label={t("environments.surveys.summary.time_to_complete")}
percentage={null}

View File

@@ -1,88 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { QuestionFilterComboBox } from "./QuestionFilterComboBox";
describe("QuestionFilterComboBox", () => {
afterEach(() => {
cleanup();
});
const defaultProps = {
filterOptions: ["A", "B"],
filterComboBoxOptions: ["X", "Y"],
filterValue: undefined,
filterComboBoxValue: undefined,
onChangeFilterValue: vi.fn(),
onChangeFilterComboBoxValue: vi.fn(),
handleRemoveMultiSelect: vi.fn(),
disabled: false,
};
test("renders select placeholders", () => {
render(<QuestionFilterComboBox {...defaultProps} />);
expect(screen.getAllByText(/common.select\.../).length).toBe(2);
});
test("calls onChangeFilterValue when selecting filter", async () => {
render(<QuestionFilterComboBox {...defaultProps} />);
await userEvent.click(screen.getAllByRole("button")[0]);
await userEvent.click(screen.getByText("A"));
expect(defaultProps.onChangeFilterValue).toHaveBeenCalledWith("A");
});
test("calls onChangeFilterComboBoxValue when selecting combo box option", async () => {
render(<QuestionFilterComboBox {...defaultProps} filterValue="A" />);
await userEvent.click(screen.getAllByRole("button")[1]);
await userEvent.click(screen.getByText("X"));
expect(defaultProps.onChangeFilterComboBoxValue).toHaveBeenCalledWith("X");
});
test("multi-select removal works", async () => {
const props = {
...defaultProps,
type: "multipleChoiceMulti",
filterValue: "A",
filterComboBoxValue: ["X", "Y"],
};
render(<QuestionFilterComboBox {...props} />);
const removeButtons = screen.getAllByRole("button", { name: /X/i });
await userEvent.click(removeButtons[0]);
expect(props.handleRemoveMultiSelect).toHaveBeenCalledWith(["Y"]);
});
test("disabled state prevents opening", async () => {
render(<QuestionFilterComboBox {...defaultProps} disabled />);
await userEvent.click(screen.getAllByRole("button")[0]);
expect(screen.queryByText("A")).toBeNull();
});
test("handles object options correctly", async () => {
const obj = { default: "Obj1", en: "ObjEN" };
const props = {
...defaultProps,
type: "multipleChoiceMulti",
filterValue: "A",
filterComboBoxOptions: [obj],
filterComboBoxValue: [],
} as any;
render(<QuestionFilterComboBox {...props} />);
await userEvent.click(screen.getAllByRole("button")[1]);
await userEvent.click(screen.getByText("Obj1"));
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith(["Obj1"]);
});
test("prevent combo-box opening when filterValue is Submitted", async () => {
const props = { ...defaultProps, type: "NPS", filterValue: "Submitted" } as any;
render(<QuestionFilterComboBox {...props} />);
await userEvent.click(screen.getAllByRole("button")[1]);
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
});
test("prevent combo-box opening when filterValue is Skipped", async () => {
const props = { ...defaultProps, type: "Rating", filterValue: "Skipped" } as any;
render(<QuestionFilterComboBox {...props} />);
await userEvent.click(screen.getAllByRole("button")[1]);
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
});
});

View File

@@ -81,39 +81,6 @@ export const QuestionFilterComboBox = ({
.includes(searchQuery.toLowerCase())
);
const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? (
<p className="text-slate-600">{filterComboBoxValue}</p>
) : (
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
{typeof filterComboBoxValue !== "string" &&
filterComboBoxValue?.map((o, index) => (
<button
key={`${o}-${index}`}
type="button"
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
{o}
<X width={14} height={14} className="ml-2" />
</button>
))}
</div>
);
const commandItemOnSelect = (o: string) => {
if (!isMultiple) {
onChangeFilterComboBoxValue(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o);
} else {
onChangeFilterComboBoxValue(
Array.isArray(filterComboBoxValue)
? [...filterComboBoxValue, typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
);
}
if (!isMultiple) {
setOpen(false);
}
};
return (
<div className="inline-flex w-full flex-row">
{filterOptions && filterOptions?.length <= 1 ? (
@@ -163,37 +130,39 @@ export const QuestionFilterComboBox = ({
)}
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
<div
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
className={clsx(
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
filterComboBoxItem
) : (
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"flex-1 text-left text-slate-400",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{t("common.select")}...
</button>
)}
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"ml-2 flex items-center justify-center",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{open ? (
<ChevronUp className="h-4 w-4 opacity-50" />
{filterComboBoxValue && filterComboBoxValue?.length > 0 ? (
!Array.isArray(filterComboBoxValue) ? (
<p className="text-slate-600">{filterComboBoxValue}</p>
) : (
<ChevronDown className="h-4 w-4 opacity-50" />
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
{typeof filterComboBoxValue !== "string" &&
filterComboBoxValue?.map((o, index) => (
<button
key={`${o}-${index}`}
type="button"
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
{o}
<X width={14} height={14} className="ml-2" />
</button>
))}
</div>
)
) : (
<p className="text-slate-400">{t("common.select")}...</p>
)}
<div>
{open ? (
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</button>
</div>
</div>
<div className="relative mt-2 h-full">
{open && (
@@ -214,7 +183,21 @@ export const QuestionFilterComboBox = ({
{filteredOptions?.map((o, index) => (
<CommandItem
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
onSelect={() => commandItemOnSelect(o)}
onSelect={() => {
!isMultiple
? onChangeFilterComboBoxValue(
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o
)
: onChangeFilterComboBoxValue(
Array.isArray(filterComboBoxValue)
? [
...filterComboBoxValue,
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o,
]
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
);
!isMultiple && setOpen(false);
}}
className="cursor-pointer">
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
</CommandItem>

View File

@@ -1,55 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { OptionsType, QuestionOption, QuestionOptions, QuestionsComboBox } from "./QuestionsComboBox";
describe("QuestionsComboBox", () => {
afterEach(() => {
cleanup();
});
const mockOptions: QuestionOptions[] = [
{
header: OptionsType.QUESTIONS,
option: [{ label: "Q1", type: OptionsType.QUESTIONS, questionType: undefined, id: "1" }],
},
{
header: OptionsType.TAGS,
option: [{ label: "Tag1", type: OptionsType.TAGS, id: "t1" }],
},
];
test("renders selected label when closed", () => {
const selected: Partial<QuestionOption> = { label: "Q1", type: OptionsType.QUESTIONS, id: "1" };
render(<QuestionsComboBox options={mockOptions} selected={selected} onChangeValue={() => {}} />);
expect(screen.getByText("Q1")).toBeInTheDocument();
});
test("opens dropdown, selects an option, and closes", async () => {
let currentSelected: Partial<QuestionOption> = {};
const onChange = vi.fn((option) => {
currentSelected = option;
});
const { rerender } = render(
<QuestionsComboBox options={mockOptions} selected={currentSelected} onChangeValue={onChange} />
);
// Open the dropdown
await userEvent.click(screen.getByRole("button"));
expect(screen.getByPlaceholderText("common.search...")).toBeInTheDocument();
// Select an option
await userEvent.click(screen.getByText("Q1"));
// Check if onChange was called
expect(onChange).toHaveBeenCalledWith(mockOptions[0].option[0]);
// Rerender with the new selected value
rerender(<QuestionsComboBox options={mockOptions} selected={currentSelected} onChangeValue={onChange} />);
// Check if the input is gone and the selected item is displayed
expect(screen.queryByPlaceholderText("common.search...")).toBeNull();
expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed
});
});

View File

@@ -34,7 +34,7 @@ import {
StarIcon,
User,
} from "lucide-react";
import { Fragment, useRef, useState } from "react";
import * as React from "react";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
export enum OptionsType {
@@ -141,15 +141,15 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOpti
};
export const QuestionsComboBox = ({ options, selected, onChangeValue }: QuestionComboBoxProps) => {
const [open, setOpen] = useState(false);
const [open, setOpen] = React.useState(false);
const { t } = useTranslate();
const commandRef = useRef(null);
const [inputValue, setInputValue] = useState("");
const commandRef = React.useRef(null);
const [inputValue, setInputValue] = React.useState("");
useClickOutside(commandRef, () => setOpen(false));
return (
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
<button
<div
onClick={() => setOpen(true)}
className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm">
{!open && selected.hasOwnProperty("label") && (
@@ -174,14 +174,14 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
)}
</div>
</button>
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => (
<Fragment key={data.header}>
<>
{data?.option.length > 0 && (
<CommandGroup
heading={<p className="text-sm font-normal text-slate-600">{data.header}</p>}>
@@ -199,7 +199,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
))}
</CommandGroup>
)}
</Fragment>
</>
))}
</CommandList>
</div>

View File

@@ -1,612 +0,0 @@
import { describe, expect, test } from "vitest";
import { TShuffleOption, TSurveyLogic, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTemplateRole } from "@formbricks/types/templates";
import {
buildCTAQuestion,
buildConsentQuestion,
buildMultipleChoiceQuestion,
buildNPSQuestion,
buildOpenTextQuestion,
buildRatingQuestion,
buildSurvey,
createChoiceJumpLogic,
createJumpLogic,
getDefaultEndingCard,
getDefaultSurveyPreset,
getDefaultWelcomeCard,
hiddenFieldsDefault,
} from "./survey-builder";
// Mock the TFnType from @tolgee/react
const mockT = (props: any): string => (typeof props === "string" ? props : props.key);
describe("Survey Builder", () => {
describe("buildMultipleChoiceQuestion", () => {
test("creates a single choice question with required fields", () => {
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: ["Option 1", "Option 2", "Option 3"],
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Test Question" },
choices: expect.arrayContaining([
expect.objectContaining({ label: { default: "Option 1" } }),
expect.objectContaining({ label: { default: "Option 2" } }),
expect.objectContaining({ label: { default: "Option 3" } }),
]),
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
shuffleOption: "none",
required: true,
});
expect(question.choices.length).toBe(3);
expect(question.id).toBeDefined();
});
test("creates a multiple choice question with provided ID", () => {
const customId = "custom-id-123";
const question = buildMultipleChoiceQuestion({
id: customId,
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
choices: ["Option 1", "Option 2"],
t: mockT,
});
expect(question.id).toBe(customId);
expect(question.type).toBe(TSurveyQuestionTypeEnum.MultipleChoiceMulti);
});
test("handles 'other' option correctly", () => {
const choices = ["Option 1", "Option 2", "Other"];
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices,
containsOther: true,
t: mockT,
});
expect(question.choices.length).toBe(3);
expect(question.choices[2].id).toBe("other");
});
test("uses provided choice IDs when available", () => {
const choiceIds = ["id1", "id2", "id3"];
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: ["Option 1", "Option 2", "Option 3"],
choiceIds,
t: mockT,
});
expect(question.choices[0].id).toBe(choiceIds[0]);
expect(question.choices[1].id).toBe(choiceIds[1]);
expect(question.choices[2].id).toBe(choiceIds[2]);
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const shuffleOption: TShuffleOption = "all";
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
subheader: "This is a subheader",
choices: ["Option 1", "Option 2"],
buttonLabel: "Custom Next",
backButtonLabel: "Custom Back",
shuffleOption,
required: false,
logic,
t: mockT,
});
expect(question.subheader).toEqual({ default: "This is a subheader" });
expect(question.buttonLabel).toEqual({ default: "Custom Next" });
expect(question.backButtonLabel).toEqual({ default: "Custom Back" });
expect(question.shuffleOption).toBe("all");
expect(question.required).toBe(false);
expect(question.logic).toBe(logic);
});
});
describe("buildOpenTextQuestion", () => {
test("creates an open text question with required fields", () => {
const question = buildOpenTextQuestion({
headline: "Open Question",
inputType: "text",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Question" },
inputType: "text",
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
charLimit: {
enabled: false,
},
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildOpenTextQuestion({
id: "custom-id",
headline: "Open Question",
subheader: "Answer this question",
placeholder: "Type here",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
longAnswer: true,
inputType: "email",
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Answer this question" });
expect(question.placeholder).toEqual({ default: "Type here" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.longAnswer).toBe(true);
expect(question.inputType).toBe("email");
expect(question.logic).toBe(logic);
});
});
describe("buildRatingQuestion", () => {
test("creates a rating question with required fields", () => {
const question = buildRatingQuestion({
headline: "Rating Question",
scale: "number",
range: 5,
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rating Question" },
scale: "number",
range: 5,
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildRatingQuestion({
id: "custom-id",
headline: "Rating Question",
subheader: "Rate us",
scale: "star",
range: 10,
lowerLabel: "Poor",
upperLabel: "Excellent",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
isColorCodingEnabled: true,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Rate us" });
expect(question.scale).toBe("star");
expect(question.range).toBe(10);
expect(question.lowerLabel).toEqual({ default: "Poor" });
expect(question.upperLabel).toEqual({ default: "Excellent" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.isColorCodingEnabled).toBe(true);
expect(question.logic).toBe(logic);
});
});
describe("buildNPSQuestion", () => {
test("creates an NPS question with required fields", () => {
const question = buildNPSQuestion({
headline: "NPS Question",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "NPS Question" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildNPSQuestion({
id: "custom-id",
headline: "NPS Question",
subheader: "How likely are you to recommend us?",
lowerLabel: "Not likely",
upperLabel: "Very likely",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
isColorCodingEnabled: true,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "How likely are you to recommend us?" });
expect(question.lowerLabel).toEqual({ default: "Not likely" });
expect(question.upperLabel).toEqual({ default: "Very likely" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.isColorCodingEnabled).toBe(true);
expect(question.logic).toBe(logic);
});
});
describe("buildConsentQuestion", () => {
test("creates a consent question with required fields", () => {
const question = buildConsentQuestion({
headline: "Consent Question",
label: "I agree to terms",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.Consent,
headline: { default: "Consent Question" },
label: { default: "I agree to terms" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildConsentQuestion({
id: "custom-id",
headline: "Consent Question",
subheader: "Please read the terms",
label: "I agree to terms",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Please read the terms" });
expect(question.label).toEqual({ default: "I agree to terms" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.logic).toBe(logic);
});
});
describe("buildCTAQuestion", () => {
test("creates a CTA question with required fields", () => {
const question = buildCTAQuestion({
headline: "CTA Question",
buttonExternal: false,
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA Question" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
buttonExternal: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildCTAQuestion({
id: "custom-id",
headline: "CTA Question",
html: "<p>Click the button</p>",
buttonLabel: "Click me",
buttonExternal: true,
buttonUrl: "https://example.com",
backButtonLabel: "Previous",
required: false,
dismissButtonLabel: "No thanks",
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.html).toEqual({ default: "<p>Click the button</p>" });
expect(question.buttonLabel).toEqual({ default: "Click me" });
expect(question.buttonExternal).toBe(true);
expect(question.buttonUrl).toBe("https://example.com");
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.dismissButtonLabel).toEqual({ default: "No thanks" });
expect(question.logic).toBe(logic);
});
test("handles external button with URL", () => {
const question = buildCTAQuestion({
headline: "CTA Question",
buttonExternal: true,
buttonUrl: "https://formbricks.com",
t: mockT,
});
expect(question.buttonExternal).toBe(true);
expect(question.buttonUrl).toBe("https://formbricks.com");
});
});
// Test combinations of parameters for edge cases
describe("Edge cases", () => {
test("multiple choice question with empty choices array", () => {
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [],
t: mockT,
});
expect(question.choices).toEqual([]);
});
test("open text question with all parameters", () => {
const question = buildOpenTextQuestion({
id: "custom-id",
headline: "Open Question",
subheader: "Answer this question",
placeholder: "Type here",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
longAnswer: true,
inputType: "email",
logic: [],
t: mockT,
});
expect(question).toMatchObject({
id: "custom-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Question" },
subheader: { default: "Answer this question" },
placeholder: { default: "Type here" },
buttonLabel: { default: "Submit" },
backButtonLabel: { default: "Previous" },
required: false,
longAnswer: true,
inputType: "email",
logic: [],
});
});
});
});
describe("Helper Functions", () => {
test("createJumpLogic returns valid jump logic", () => {
const sourceId = "q1";
const targetId = "q2";
const operator: "isClicked" = "isClicked";
const logic = createJumpLogic(sourceId, targetId, operator);
// Check structure
expect(logic).toHaveProperty("id");
expect(logic).toHaveProperty("conditions");
expect(logic.conditions).toHaveProperty("conditions");
expect(Array.isArray(logic.conditions.conditions)).toBe(true);
// Check one of the inner conditions
const condition = logic.conditions.conditions[0];
// Need to use type checking to ensure condition is a TSingleCondition not a TConditionGroup
if (!("connector" in condition)) {
expect(condition.leftOperand.value).toBe(sourceId);
expect(condition.operator).toBe(operator);
}
// Check actions
expect(Array.isArray(logic.actions)).toBe(true);
const action = logic.actions[0];
if (action.objective === "jumpToQuestion") {
expect(action.target).toBe(targetId);
}
});
test("createChoiceJumpLogic returns valid jump logic based on choice selection", () => {
const sourceId = "q1";
const choiceId = "choice1";
const targetId = "q2";
const logic = createChoiceJumpLogic(sourceId, choiceId, targetId);
expect(logic).toHaveProperty("id");
expect(logic.conditions).toHaveProperty("conditions");
const condition = logic.conditions.conditions[0];
if (!("connector" in condition)) {
expect(condition.leftOperand.value).toBe(sourceId);
expect(condition.operator).toBe("equals");
expect(condition.rightOperand?.value).toBe(choiceId);
}
const action = logic.actions[0];
if (action.objective === "jumpToQuestion") {
expect(action.target).toBe(targetId);
}
});
test("getDefaultWelcomeCard returns expected welcome card", () => {
const card = getDefaultWelcomeCard(mockT);
expect(card.enabled).toBe(false);
expect(card.headline).toEqual({ default: "templates.default_welcome_card_headline" });
expect(card.html).toEqual({ default: "templates.default_welcome_card_html" });
expect(card.buttonLabel).toEqual({ default: "templates.default_welcome_card_button_label" });
// boolean flags
expect(card.timeToFinish).toBe(false);
expect(card.showResponseCount).toBe(false);
});
test("getDefaultEndingCard returns expected end screen card", () => {
// Pass empty languages array to simulate no languages
const card = getDefaultEndingCard([], mockT);
expect(card).toHaveProperty("id");
expect(card.type).toBe("endScreen");
expect(card.headline).toEqual({ default: "templates.default_ending_card_headline" });
expect(card.subheader).toEqual({ default: "templates.default_ending_card_subheader" });
expect(card.buttonLabel).toEqual({ default: "templates.default_ending_card_button_label" });
expect(card.buttonLink).toBe("https://formbricks.com");
});
test("getDefaultSurveyPreset returns expected default survey preset", () => {
const preset = getDefaultSurveyPreset(mockT);
expect(preset.name).toBe("New Survey");
expect(preset.questions).toEqual([]);
// test welcomeCard and endings
expect(preset.welcomeCard).toHaveProperty("headline");
expect(Array.isArray(preset.endings)).toBe(true);
expect(preset.hiddenFields).toEqual(hiddenFieldsDefault);
});
test("buildSurvey returns built survey with overridden preset properties", () => {
const config = {
name: "Custom Survey",
role: "productManager" as TTemplateRole,
industries: ["eCommerce"] as string[],
channels: ["link"],
description: "Test survey",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText, // changed from "OpenText"
headline: { default: "Question 1" },
inputType: "text",
buttonLabel: { default: "Next" },
backButtonLabel: { default: "Back" },
required: true,
},
],
endings: [
{
id: "end1",
type: "endScreen",
headline: { default: "End Screen" },
subheader: { default: "Thanks" },
buttonLabel: { default: "Finish" },
buttonLink: "https://formbricks.com",
},
],
hiddenFields: { enabled: false, fieldIds: ["f1"] },
};
const survey = buildSurvey(config as any, mockT);
expect(survey.name).toBe(config.name);
expect(survey.role).toBe(config.role);
expect(survey.industries).toEqual(config.industries);
expect(survey.channels).toEqual(config.channels);
expect(survey.description).toBe(config.description);
// preset overrides
expect(survey.preset.name).toBe(config.name);
expect(survey.preset.questions).toEqual(config.questions);
expect(survey.preset.endings).toEqual(config.endings);
expect(survey.preset.hiddenFields).toEqual(config.hiddenFields);
});
test("hiddenFieldsDefault has expected default configuration", () => {
expect(hiddenFieldsDefault).toEqual({ enabled: true, fieldIds: [] });
});
});

View File

@@ -1,414 +0,0 @@
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { createId } from "@paralleldrive/cuid2";
import { TFnType } from "@tolgee/react";
import {
TShuffleOption,
TSurveyCTAQuestion,
TSurveyConsentQuestion,
TSurveyEndScreenCard,
TSurveyEnding,
TSurveyHiddenFields,
TSurveyLanguage,
TSurveyLogic,
TSurveyMultipleChoiceQuestion,
TSurveyNPSQuestion,
TSurveyOpenTextQuestion,
TSurveyOpenTextQuestionInputType,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyRatingQuestion,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
const defaultButtonLabel = "common.next";
const defaultBackButtonLabel = "common.back";
export const buildMultipleChoiceQuestion = ({
id,
headline,
type,
subheader,
choices,
choiceIds,
buttonLabel,
backButtonLabel,
shuffleOption,
required,
logic,
containsOther = false,
t,
}: {
id?: string;
headline: string;
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti | TSurveyQuestionTypeEnum.MultipleChoiceSingle;
subheader?: string;
choices: string[];
choiceIds?: string[];
buttonLabel?: string;
backButtonLabel?: string;
shuffleOption?: TShuffleOption;
required?: boolean;
logic?: TSurveyLogic[];
containsOther?: boolean;
t: TFnType;
}): TSurveyMultipleChoiceQuestion => {
return {
id: id ?? createId(),
type,
subheader: subheader ? { default: subheader } : undefined,
headline: { default: headline },
choices: choices.map((choice, index) => {
const isLastIndex = index === choices.length - 1;
const id = containsOther && isLastIndex ? "other" : choiceIds ? choiceIds[index] : createId();
return { id, label: { default: choice } };
}),
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
shuffleOption: shuffleOption || "none",
required: required ?? true,
logic,
};
};
export const buildOpenTextQuestion = ({
id,
headline,
subheader,
placeholder,
inputType,
buttonLabel,
backButtonLabel,
required,
logic,
longAnswer,
t,
}: {
id?: string;
headline: string;
subheader?: string;
placeholder?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
inputType: TSurveyOpenTextQuestionInputType;
longAnswer?: boolean;
t: TFnType;
}): TSurveyOpenTextQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.OpenText,
inputType,
subheader: subheader ? { default: subheader } : undefined,
placeholder: placeholder ? { default: placeholder } : undefined,
headline: { default: headline },
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
required: required ?? true,
longAnswer,
logic,
charLimit: {
enabled: false,
},
};
};
export const buildRatingQuestion = ({
id,
headline,
subheader,
scale,
range,
lowerLabel,
upperLabel,
buttonLabel,
backButtonLabel,
required,
logic,
isColorCodingEnabled = false,
t,
}: {
id?: string;
headline: string;
scale: TSurveyRatingQuestion["scale"];
range: TSurveyRatingQuestion["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
placeholder?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
isColorCodingEnabled?: boolean;
t: TFnType;
}): TSurveyRatingQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.Rating,
subheader: subheader ? { default: subheader } : undefined,
headline: { default: headline },
scale,
range,
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
required: required ?? true,
isColorCodingEnabled,
lowerLabel: lowerLabel ? { default: lowerLabel } : undefined,
upperLabel: upperLabel ? { default: upperLabel } : undefined,
logic,
};
};
export const buildNPSQuestion = ({
id,
headline,
subheader,
lowerLabel,
upperLabel,
buttonLabel,
backButtonLabel,
required,
logic,
isColorCodingEnabled = false,
t,
}: {
id?: string;
headline: string;
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
placeholder?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
isColorCodingEnabled?: boolean;
t: TFnType;
}): TSurveyNPSQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.NPS,
subheader: subheader ? { default: subheader } : undefined,
headline: { default: headline },
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
required: required ?? true,
isColorCodingEnabled,
lowerLabel: lowerLabel ? { default: lowerLabel } : undefined,
upperLabel: upperLabel ? { default: upperLabel } : undefined,
logic,
};
};
export const buildConsentQuestion = ({
id,
headline,
subheader,
label,
buttonLabel,
backButtonLabel,
required,
logic,
t,
}: {
id?: string;
headline: string;
subheader?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
label: string;
t: TFnType;
}): TSurveyConsentQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.Consent,
subheader: subheader ? { default: subheader } : undefined,
headline: { default: headline },
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
required: required ?? true,
label: { default: label },
logic,
};
};
export const buildCTAQuestion = ({
id,
headline,
html,
buttonLabel,
buttonExternal,
backButtonLabel,
required,
logic,
dismissButtonLabel,
buttonUrl,
t,
}: {
id?: string;
headline: string;
buttonExternal: boolean;
html?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
dismissButtonLabel?: string;
buttonUrl?: string;
t: TFnType;
}): TSurveyCTAQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.CTA,
html: html ? { default: html } : undefined,
headline: { default: headline },
buttonLabel: { default: buttonLabel || t(defaultButtonLabel) },
backButtonLabel: { default: backButtonLabel || t(defaultBackButtonLabel) },
dismissButtonLabel: dismissButtonLabel ? { default: dismissButtonLabel } : undefined,
required: required ?? true,
buttonExternal,
buttonUrl,
logic,
};
};
// Helper function to create standard jump logic based on operator
export const createJumpLogic = (
sourceQuestionId: string,
targetId: string,
operator: "isSkipped" | "isSubmitted" | "isClicked"
): TSurveyLogic => ({
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: sourceQuestionId,
type: "question",
},
operator: operator,
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: targetId,
},
],
});
// Helper function to create jump logic based on choice selection
export const createChoiceJumpLogic = (
sourceQuestionId: string,
choiceId: string,
targetId: string
): TSurveyLogic => ({
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: sourceQuestionId,
type: "question",
},
operator: "equals",
rightOperand: {
type: "static",
value: choiceId,
},
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: targetId,
},
],
});
export const getDefaultEndingCard = (languages: TSurveyLanguage[], t: TFnType): TSurveyEndScreenCard => {
const languageCodes = extractLanguageCodes(languages);
return {
id: createId(),
type: "endScreen",
headline: createI18nString(t("templates.default_ending_card_headline"), languageCodes),
subheader: createI18nString(t("templates.default_ending_card_subheader"), languageCodes),
buttonLabel: createI18nString(t("templates.default_ending_card_button_label"), languageCodes),
buttonLink: "https://formbricks.com",
};
};
export const hiddenFieldsDefault: TSurveyHiddenFields = {
enabled: true,
fieldIds: [],
};
export const getDefaultWelcomeCard = (t: TFnType): TSurveyWelcomeCard => {
return {
enabled: false,
headline: { default: t("templates.default_welcome_card_headline") },
html: { default: t("templates.default_welcome_card_html") },
buttonLabel: { default: t("templates.default_welcome_card_button_label") },
timeToFinish: false,
showResponseCount: false,
};
};
export const getDefaultSurveyPreset = (t: TFnType): TTemplate["preset"] => {
return {
name: "New Survey",
welcomeCard: getDefaultWelcomeCard(t),
endings: [getDefaultEndingCard([], t)],
hiddenFields: hiddenFieldsDefault,
questions: [],
};
};
/**
* Generic builder for survey.
* @param config - The configuration for survey settings and questions.
* @param t - The translation function.
*/
export const buildSurvey = (
config: {
name: string;
role: TTemplateRole;
industries: ("eCommerce" | "saas" | "other")[];
channels: ("link" | "app" | "website")[];
description: string;
questions: TSurveyQuestion[];
endings?: TSurveyEnding[];
hiddenFields?: TSurveyHiddenFields;
},
t: TFnType
): TTemplate => {
const localSurvey = getDefaultSurveyPreset(t);
return {
name: config.name,
role: config.role,
industries: config.industries,
channels: config.channels,
description: config.description,
preset: {
...localSurvey,
name: config.name,
questions: config.questions,
endings: config.endings ?? localSurvey.endings,
hiddenFields: config.hiddenFields ?? hiddenFieldsDefault,
},
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -11,11 +11,11 @@ export const getOriginalFileNameFromUrl = (fileURL: string) => {
const fileId = fileNameFromURL?.split("--fid--")[1] ?? "";
if (!fileId) {
const fileName = originalFileName ? decodeURIComponent(originalFileName || "") : "";
const fileName = originalFileName ? decodeURIComponent(originalFileName) : "";
return fileName;
}
const fileName = originalFileName ? decodeURIComponent(`${originalFileName}.${fileExt}` || "") : "";
const fileName = originalFileName ? decodeURIComponent(`${originalFileName}.${fileExt}`) : "";
return fileName;
} catch (error) {
logger.error(error, "Error parsing file URL");
@@ -28,7 +28,7 @@ export const getFileNameWithIdFromUrl = (fileURL: string) => {
? fileURL.split("/").pop()
: new URL(fileURL).pathname.split("/").pop();
return fileNameFromURL ? decodeURIComponent(fileNameFromURL || "") : "";
return fileNameFromURL ? decodeURIComponent(fileNameFromURL) : "";
} catch (error) {
logger.error(error, "Error parsing file URL");
}

View File

@@ -9,8 +9,7 @@ export const getBillingPeriodStartDate = (billing: TOrganizationBilling): Date =
} else if (billing.period === "yearly" && billing.periodStart) {
// For yearly plans, use the same day of the month as the original subscription date
const periodStart = new Date(billing.periodStart);
// Use UTC to avoid timezone-offset shifting when parsing ISO date-only strings
const subscriptionDay = periodStart.getUTCDate();
const subscriptionDay = periodStart.getDate();
// Helper function to get the last day of a specific month
const getLastDayOfMonth = (year: number, month: number): number => {

View File

@@ -28,7 +28,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
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
<div
key={surveyLanguage.language.code}
className="rounded-md p-2 hover:cursor-pointer hover:bg-slate-700"
onClick={() => {
@@ -36,7 +36,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
setShowLanguageSelect(false);
}}>
{getLanguageLabel(surveyLanguage.language.code, locale)}
</button>
</div>
))}
</div>
)}

View File

@@ -204,7 +204,7 @@ export const LoginForm = ({
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full pr-8 rounded-md border-slate-300 shadow-sm sm:text-sm"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>

View File

@@ -10,12 +10,8 @@ const LoadingCard = () => {
return (
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-slate-100 text-lg leading-6 font-medium">
<span className="sr-only">{t("common.loading")}</span>
</h3>
<p className="mt-3 h-4 w-full max-w-80 animate-pulse rounded-lg bg-slate-100 text-sm text-slate-500">
<span className="sr-only">{t("common.loading")}</span>
</p>
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-slate-100 text-lg font-medium leading-6"></h3>
<p className="mt-3 h-4 w-full max-w-80 animate-pulse rounded-lg bg-slate-100 text-sm text-slate-500"></p>
</div>
<div className="w-full">
<div className="rounded-lg px-4 pt-4">
@@ -28,9 +24,7 @@ const LoadingCard = () => {
<div className="col-span-4 sm:col-span-2">{t("common.created_at")}</div>
</div>
<div className="px-6">
<div className="my-4 h-5 w-full animate-pulse rounded-full bg-slate-200">
<span className="sr-only">{t("common.loading")}</span>
</div>
<div className="my-4 h-5 w-full animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
<div className="flex justify-start">

View File

@@ -105,7 +105,7 @@ export const TemplateList = ({
};
return (
<main className="relative z-0 flex-1 overflow-y-auto px-6 pt-2 pb-6 focus:outline-none">
<main className="relative z-0 flex-1 overflow-y-auto px-6 pb-6 pt-2 focus:outline-none">
{showFilters && !templateSearch && (
<TemplateFilters
selectedFilter={selectedFilter}

View File

@@ -1,6 +1,6 @@
"use client";
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
import { getDefaultEndingCard } from "@/app/lib/templates";
import { cn } from "@/lib/cn";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
@@ -106,7 +106,7 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
className="h-full w-full cursor-pointer"
id="howToSendCardTrigger">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<div className="flex items-center pr-5 pl-2">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"

View File

@@ -1,6 +1,6 @@
"use client";
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
import { getDefaultEndingCard } from "@/app/lib/templates";
import { addMultiLanguageLabels, extractLanguageCodes } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { isConditionGroup } from "@/lib/surveyLogic/utils";

View File

@@ -1,4 +1,4 @@
import { getDefaultEndingCard, getDefaultWelcomeCard } from "@/app/lib/survey-builder";
import { getDefaultEndingCard, getDefaultWelcomeCard } from "@/app/lib/templates";
import { TFnType } from "@tolgee/react";
import { TSurvey } from "@formbricks/types/surveys/types";

View File

@@ -72,11 +72,8 @@ const Alert = React.forwardRef<
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, children, ...props }, ref) => {
({ className, ...props }, ref) => {
const { size } = useAlertContext();
const headingContent = children || <span className="sr-only">Alert</span>;
return (
<h5
ref={ref}
@@ -85,9 +82,8 @@ const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<H
size === "small" ? "flex-shrink truncate" : "col-start-1 row-start-1",
className
)}
{...props}>
{headingContent}
</h5>
{...props}
/>
);
}
);

View File

@@ -172,7 +172,7 @@ export const withButtonAndIcon: Story = {
};
// Error variant
export const Destructive: Story = {
export const Error: Story = {
render: renderAlert,
args: {
variant: "error",

View File

@@ -1,154 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, test } from "vitest";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./index";
describe("Card Component", () => {
test("renders basic Card component", () => {
render(<Card data-testid="test-card">Card Content</Card>);
const card = screen.getByTestId("test-card");
expect(card).toBeInTheDocument();
expect(card).toHaveTextContent("Card Content");
expect(card).toHaveClass("rounded-xl", "border", "border-slate-200", "bg-white", "shadow-sm");
});
test("applies custom className to Card", () => {
render(
<Card data-testid="custom-card" className="custom-class">
Card Content
</Card>
);
const card = screen.getByTestId("custom-card");
expect(card).toHaveClass("custom-class");
});
test("renders CardHeader component", () => {
render(<CardHeader data-testid="test-header">Header Content</CardHeader>);
const header = screen.getByTestId("test-header");
expect(header).toBeInTheDocument();
expect(header).toHaveTextContent("Header Content");
expect(header).toHaveClass("flex", "flex-col", "space-y-1.5", "p-6");
});
test("applies custom className to CardHeader", () => {
render(
<CardHeader data-testid="custom-header" className="custom-class">
Header Content
</CardHeader>
);
const header = screen.getByTestId("custom-header");
expect(header).toHaveClass("custom-class");
});
test("renders CardTitle component", () => {
render(<CardTitle data-testid="test-title">Title Content</CardTitle>);
const title = screen.getByTestId("test-title");
expect(title).toBeInTheDocument();
expect(title).toHaveTextContent("Title Content");
expect(title).toHaveClass("text-2xl", "leading-none", "font-semibold", "tracking-tight");
});
test("renders CardTitle with sr-only when no children provided", () => {
render(<CardTitle data-testid="empty-title" />);
const title = screen.getByTestId("empty-title");
expect(title).toBeInTheDocument();
const srOnly = title.querySelector(".sr-only");
expect(srOnly).toBeInTheDocument();
expect(srOnly).toHaveTextContent("Title");
});
test("applies custom className to CardTitle", () => {
render(
<CardTitle data-testid="custom-title" className="custom-class">
Title Content
</CardTitle>
);
const title = screen.getByTestId("custom-title");
expect(title).toHaveClass("custom-class");
});
test("renders CardDescription component", () => {
render(<CardDescription data-testid="test-description">Description Content</CardDescription>);
const description = screen.getByTestId("test-description");
expect(description).toBeInTheDocument();
expect(description).toHaveTextContent("Description Content");
expect(description).toHaveClass("text-sm", "text-muted-foreground");
});
test("applies custom className to CardDescription", () => {
render(
<CardDescription data-testid="custom-description" className="custom-class">
Description Content
</CardDescription>
);
const description = screen.getByTestId("custom-description");
expect(description).toHaveClass("custom-class");
});
test("renders CardContent component", () => {
render(<CardContent data-testid="test-content">Content</CardContent>);
const content = screen.getByTestId("test-content");
expect(content).toBeInTheDocument();
expect(content).toHaveTextContent("Content");
expect(content).toHaveClass("p-6", "pt-0");
});
test("applies custom className to CardContent", () => {
render(
<CardContent data-testid="custom-content" className="custom-class">
Content
</CardContent>
);
const content = screen.getByTestId("custom-content");
expect(content).toHaveClass("custom-class");
});
test("renders CardFooter component", () => {
render(<CardFooter data-testid="test-footer">Footer Content</CardFooter>);
const footer = screen.getByTestId("test-footer");
expect(footer).toBeInTheDocument();
expect(footer).toHaveTextContent("Footer Content");
expect(footer).toHaveClass("flex", "items-center", "p-6", "pt-0");
});
test("applies custom className to CardFooter", () => {
render(
<CardFooter data-testid="custom-footer" className="custom-class">
Footer Content
</CardFooter>
);
const footer = screen.getByTestId("custom-footer");
expect(footer).toHaveClass("custom-class");
});
test("renders full Card with all subcomponents", () => {
render(
<Card data-testid="full-card">
<CardHeader>
<CardTitle>Test Title</CardTitle>
<CardDescription>Test Description</CardDescription>
</CardHeader>
<CardContent>Test Content</CardContent>
<CardFooter>Test Footer</CardFooter>
</Card>
);
const card = screen.getByTestId("full-card");
expect(card).toBeInTheDocument();
expect(screen.getByText("Test Title")).toBeInTheDocument();
expect(screen.getByText("Test Description")).toBeInTheDocument();
expect(screen.getByText("Test Content")).toBeInTheDocument();
expect(screen.getByText("Test Footer")).toBeInTheDocument();
});
test("passes extra props to Card", () => {
render(
<Card data-testid="props-card" aria-label="Card with props" role="region">
Test
</Card>
);
const card = screen.getByTestId("props-card");
expect(card).toHaveAttribute("aria-label", "Card with props");
expect(card).toHaveAttribute("role", "region");
});
});

View File

@@ -51,18 +51,13 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, children, ...props }, ref) => {
const headingContent = children || <span className="sr-only">Title</span>;
return (
<h3
ref={ref}
className={cn("text-2xl leading-none font-semibold tracking-tight", className)}
{...props}>
{headingContent}
</h3>
);
}
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-2xl leading-none font-semibold tracking-tight", className)}
{...props}
/>
)
);
CardTitle.displayName = "CardTitle";

View File

@@ -25,9 +25,8 @@
.fb-editor-heading-h1 {
font-size: 25px !important;
font-weight: 400 !important;
margin-bottom: 20px !important;
font-weight: bold !important;
margin-bottom: 20px !important;
}
.fb-editor-heading-h2 {

View File

@@ -44,9 +44,8 @@
font-size: 14px;
position: relative;
tab-size: 1;
outline: 0;
padding: 10px 10px;
outline: none;
padding: 10px 10px;
}
pre::-webkit-scrollbar {
@@ -121,7 +120,6 @@ pre::-webkit-scrollbar-thumb {
.toolbar .toolbar-item .text {
line-height: 20px;
width: 200px;
vertical-align: middle;
font-size: 14px;
text-overflow: ellipsis;

View File

@@ -13,7 +13,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
className={cn(
blur && "backdrop-blur-md",
"bg-opacity-30 fixed inset-0 z-50",
"bg-opacity-30 fixed inset-0 z-50 bg-slate-500",
"data-[state='closed']:animate-fadeOut data-[state='open']:animate-fadeIn"
)}
{...props}

View File

@@ -1,105 +0,0 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { HalfCircle, ProgressBar } from ".";
describe("ProgressBar", () => {
afterEach(() => {
cleanup();
});
test("renders with default height and correct progress", () => {
const { container } = render(<ProgressBar progress={0.5} barColor="bg-blue-500" />);
const outerDiv = container.firstChild as HTMLElement;
const innerDiv = outerDiv.firstChild as HTMLElement;
expect(outerDiv).toHaveClass("h-5"); // Default height
expect(outerDiv).toHaveClass("w-full rounded-full bg-slate-200");
expect(innerDiv).toHaveClass("h-full rounded-full bg-blue-500");
expect(innerDiv.style.width).toBe("50%");
});
test("renders with specified height (h-2)", () => {
const { container } = render(<ProgressBar progress={0.75} barColor="bg-green-500" height={2} />);
const outerDiv = container.firstChild as HTMLElement;
const innerDiv = outerDiv.firstChild as HTMLElement;
expect(outerDiv).toHaveClass("h-2"); // Specified height
expect(innerDiv).toHaveClass("bg-green-500");
expect(innerDiv.style.width).toBe("75%");
});
test("caps progress at 100%", () => {
const { container } = render(<ProgressBar progress={1.2} barColor="bg-red-500" />);
const innerDiv = (container.firstChild as HTMLElement).firstChild as HTMLElement;
expect(innerDiv.style.width).toBe("100%");
});
test("handles progress less than 0%", () => {
const { container } = render(<ProgressBar progress={-0.1} barColor="bg-yellow-500" />);
const innerDiv = (container.firstChild as HTMLElement).firstChild as HTMLElement;
expect(innerDiv.style.width).toBe("0%");
});
test("applies barColor class", () => {
const testColor = "bg-purple-600";
const { container } = render(<ProgressBar progress={0.3} barColor={testColor} />);
const innerDiv = (container.firstChild as HTMLElement).firstChild as HTMLElement;
expect(innerDiv).toHaveClass(testColor);
});
});
describe("HalfCircle", () => {
afterEach(() => {
cleanup();
});
test("renders correctly with a given value", () => {
const testValue = 50;
const { getByText, container } = render(<HalfCircle value={testValue} />);
// Check if boundary values and the main value are rendered
expect(getByText("-100")).toBeInTheDocument();
expect(getByText("100")).toBeInTheDocument();
expect(getByText(Math.round(testValue).toString())).toBeInTheDocument();
// Check rotation calculation: normalized = (50 + 100) / 200 = 0.75; mapped = (0.75 * 180 - 180) = -45deg
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
expect(rotatingDiv).toBeInTheDocument();
expect(rotatingDiv.style.rotate).toBe("-45deg");
});
test("renders correctly with value -100", () => {
const testValue = -100;
const { getAllByText, getByText, container } = render(<HalfCircle value={testValue} />);
// Check boundary labels
expect(getAllByText("-100")[0]).toBeInTheDocument();
expect(getByText("100")).toBeInTheDocument();
// Check the main value using a more specific selector
const mainValueElement = container.querySelector(".text-2xl.text-black");
expect(mainValueElement).toBeInTheDocument();
expect(mainValueElement?.textContent).toBe(Math.round(testValue).toString());
// normalized = (-100 + 100) / 200 = 0; mapped = (0 * 180 - 180) = -180deg
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
expect(rotatingDiv.style.rotate).toBe("-180deg");
});
test("renders correctly with value 100", () => {
const testValue = 100;
const { getAllByText, container } = render(<HalfCircle value={testValue} />);
expect(getAllByText(Math.round(testValue).toString())[0]).toBeInTheDocument();
// normalized = (100 + 100) / 200 = 1; mapped = (1 * 180 - 180) = 0deg
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
expect(rotatingDiv.style.rotate).toBe("0deg");
});
test("renders correctly with value 0", () => {
const testValue = 0;
const { getByText, container } = render(<HalfCircle value={testValue} />);
expect(getByText(Math.round(testValue).toString())).toBeInTheDocument();
// normalized = (0 + 100) / 200 = 0.5; mapped = (0.5 * 180 - 180) = -90deg
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
expect(rotatingDiv.style.rotate).toBe("-90deg");
});
});

View File

@@ -9,24 +9,11 @@ interface ProgressBarProps {
}
export const ProgressBar: React.FC<ProgressBarProps> = ({ progress, barColor, height = 5 }) => {
const heightClass = () => {
switch (height) {
case 2:
return "h-2";
case 5:
return "h-5";
default:
return "";
}
};
const maxWidth = Math.floor(Math.max(0, Math.min(progress, 1)) * 100);
return (
<div className={cn(heightClass(), "w-full rounded-full bg-slate-200")}>
<div className={cn(height === 2 ? "h-2" : height === 5 ? "h-5" : "", "w-full rounded-full bg-slate-200")}>
<div
className={cn("h-full rounded-full", barColor)}
style={{ width: `${maxWidth}%`, transition: "width 0.5s ease-out" }}></div>
style={{ width: `${Math.floor(progress * 100)}%`, transition: "width 0.5s ease-out" }}></div>
</div>
);
};

View File

@@ -21,7 +21,6 @@ const nextConfig = {
serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
outputFileTracingIncludes: {
"app/api/packages": ["../../packages/js-core/dist/*", "../../packages/surveys/dist/*"],
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
},
i18n: {
locales: ["en-US", "de-DE", "fr-FR", "pt-BR", "zh-Hant-TW", "pt-PT"],

View File

@@ -121,7 +121,7 @@ test.describe("JS Package Test", async () => {
await page.locator("#questionCard-4").getByLabel("textarea").fill("Much higher response rates!");
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
await page.locator("#questionCard-5").getByLabel("textarea").fill("Make this end to end test pass!");
await page.locator("#questionCard-5").getByRole("button", { name: "Next" }).click();
await page.getByRole("button", { name: "Finish" }).click();
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
await page.waitForLoadState("networkidle");

View File

@@ -35,7 +35,6 @@ export default defineConfig({
"modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx",
"modules/ui/components/alert/*.tsx",
"modules/ui/components/environmentId-base-layout/*.tsx",
"modules/ui/components/progress-bar/index.tsx",
"app/(app)/environments/**/layout.tsx",
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
"app/(app)/environments/**/components/PosthogIdentify.tsx",
@@ -48,20 +47,6 @@ export default defineConfig({
"app/intercom/*.tsx",
"app/sentry/*.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SurveyAnalysisCTA.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/ConsentSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/MatrixQuestionSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/MultipleChoiceSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/NPSSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/PictureChoiceSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/RatingSummary.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SummaryMetadata.tsx",
"app/(app)/environments/**/surveys/**/components/QuestionFilterComboBox.tsx",
"app/(app)/environments/**/surveys/**/components/QuestionsComboBox.tsx",
"app/(app)/environments/**/integrations/airtable/components/ManageIntegration.tsx",
"app/(app)/environments/**/integrations/google-sheets/components/ManageIntegration.tsx",
"apps/web/app/(app)/environments/**/integrations/notion/components/ManageIntegration.tsx",
"app/(app)/environments/**/integrations/slack/components/ManageIntegration.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/responses/components/ResponseTableCell.tsx",
"modules/ee/sso/lib/**/*.ts",
"app/lib/**/*.ts",
"app/api/(internal)/insights/lib/**/*.ts",
@@ -89,12 +74,9 @@ export default defineConfig({
"modules/account/**/*.ts",
"modules/analysis/**/*.tsx",
"modules/analysis/**/*.ts",
"app/lib/survey-builder.ts",
"modules/survey/editor/components/end-screen-form.tsx",
"lib/utils/billing.ts",
"lib/crypto.ts",
"lib/utils/billing.ts",
"modules/ui/components/card/index.tsx"
"lib/utils/billing.ts"
],
exclude: [
"**/.next/**",

View File

@@ -28,11 +28,6 @@ Integrate the **Formbricks App Survey SDK** into your app using multiple options
<Card title="React Native" icon="react" color="lightblue" href="#react-native">
[Easily integrate our SDK with your React Native app for seamless survey support.](https://formbricks.com/docs/app-surveys/framework-guides#react-native)
</Card>
<Card title="Swift" icon="swift" color="orange" href="#swift">
[Use our iOS SDK to quickly integrate surveys into your iOS applications.](https://formbricks.com/docs/app-surveys/framework-guides#swift)
</Card>
</CardGroup>
## Prerequisites
@@ -104,7 +99,7 @@ function App() {
export default App;
```
### Required Customizations
## Required Customizations
| Name | Type | Description |
| -------------- | ------ | -------------------------------------- |
@@ -318,91 +313,7 @@ export default function App() {
}
```
### Required Customizations
| Name | Type | Description |
| -------------- | ------ | -------------------------------------- |
| environment-id | string | Formbricks Environment ID. |
| app-url | string | URL of the hosted Formbricks instance. |
Now, visit the [Validate Your Setup](#validate-your-setup) section to verify your setup!
## Swift
Install the Formbricks iOS SDK using the following steps:
**Swift Package Manager**
1. In Xcode choose **File → Add Packages…**
2. Enter your repo URL (e.g. `https://github.com/formbricks/ios.git`)
3. Choose version rule (e.g. "Up to Next Major" starting at `1.0.0`).
4. Import in your code:
```swift
import FormbricksSDK
```
**CocoaPods**
1. Add the following to your `Podfile`:
```ruby
platform :ios, '16.6'
use_frameworks! :linkage => :static
target 'YourTargetName' do
pod 'FormbricksSDK', '1.0.0 (or the latest version)'
end
```
2. Run `pod install` in your project directory
3. Import in your code:
```swift
import FormbricksSDK
```
Now start using FormbricksSDK
```swift
import FormbricksSDK
// 1. Build your config (you can also inject userId + attributes here)
let config = FormbricksConfig.Builder(
appUrl: "https://yourapp.bricks.com",
environmentId: "YOUR_ENV_ID"
)
.setLogLevel(.debug)
.build()
// 2. Initialize the SDK (once per launch)
Formbricks.setup(with: config)
// 3. Identify the user
Formbricks.setUserId("user123")
// 4. Track events
Formbricks.track("button_pressed")
// 5. Set or add user attributes
Formbricks.setAttribute("blue", forKey: "favoriteColor")
Formbricks.setAttributes([
"plan": "pro",
"tier": "gold"
])
// 6. Change language (no userId required):
Formbricks.setLanguage("de")
// 7. Log out (no userId required):
Formbricks.logout()
// 8. Clean up SDK state (optional):
Formbricks.cleanup(waitForOperations: true) {
print("SDK torn down")
}
```
### Required Customizations
## Required Customizations
| Name | Type | Description |
| -------------- | ------ | -------------------------------------- |

View File

@@ -42,7 +42,7 @@ To run the Churn Survey in your app you want to proceed as follows:
If you don't have an account yet, create one at [app.formbricks.com](https://app.formbricks.com/auth/signup)
Click on "Create Survey" and choose the template [Churn Survey](https://formbricks.com/survey-templates/churn-survey):
Click on "Create Survey" and choose the template Churn Survey:
![Create churn survey by template](/images/xm-and-surveys/xm/best-practices/cancel-subscription/create-cancel-flow.webp)

View File

@@ -31,7 +31,7 @@ To get this running, you'll need a bit of time. Here are the steps we're going t
![switch to dev environment](/images/xm-and-surveys/xm/best-practices/docs-feedback/switch-to-dev.webp)
- Then, create a survey using the template [Docs Feedback](https://formbricks.com/survey-templates/docs-feedback):
- Then, create a survey using the template Docs Feedback:
![select docs template](/images/xm-and-surveys/xm/best-practices/docs-feedback/docs-template.webp)

View File

@@ -35,7 +35,7 @@ To run the Feature Chaser survey in your app you want to proceed as follows:
If you don't have an account yet, create one at [app.formbricks.com](https://app.formbricks.com/auth/signup)
Click on "Create Survey" and choose the template [Feature Chaser](https://formbricks.com/survey-templates/feature-chaser):
Click on "Create Survey" and choose the template Feature Chaser:
![Create survey by template](/images/xm-and-surveys/xm/best-practices/feature-chaser/create-survey.webp)

View File

@@ -30,7 +30,7 @@ To add the Feedback Box to your app, you need to perform these steps:
If you don't have an account yet, create one at [app.formbricks.com](https://app.formbricks.com/auth/signup)
Then, create a new survey and look for the [Feedback Box template](https://formbricks.com/survey-templates/feedback-box):
Then, create a new survey and look for the "Feedback Box" template:
![Create feedback box by template](/images/xm-and-surveys/xm/best-practices/feedback-box/create-feedback-box-by-template.webp)

View File

@@ -33,7 +33,7 @@ To embed the newsletter survey into your email, follow these steps:
If you don't have an account yet, create one at [app.formbricks.com](https://app.formbricks.com/auth/signup)
Then, create a new survey and look for the [Improve Newsletter Content template](https://formbricks.com/survey-templates/improve-newsletter-content):
Then, create a new survey and look for the "Improve Newsletter Content" template:
![Create Improve Newsletter Content by template](/images/xm-and-surveys/xm/best-practices/improve-email-content/improve-newsletter-content-survey-location.webp)

View File

@@ -32,7 +32,7 @@ To display the Trial Conversion Survey in your app you want to proceed as follow
If you don't have an account yet, create one at [app.formbricks.com](https://app.formbricks.com/auth/signup)
Click on **Create Survey** and select the template, [Improve Trial Conversion](https://formbricks.com/survey-templates/improve-trial-conversion):
Click on **Create Survey** and select the template, **Improve Trial Conversion**:
![Create survey by template](/images/xm-and-surveys/xm/best-practices/improve-trial-cr/create-survey.webp)

View File

@@ -38,7 +38,7 @@ To display an Interview Prompt in your app you want to proceed as follows:
If you don't have an account yet, create one at [app.formbricks.com](https://app.formbricks.com/auth/signup)
Click on "Create Survey" and choose the template [Interview Prompt](https://formbricks.com/survey-templates/interview-prompt):
Click on "Create Survey" and choose the template Interview Prompt:
![Create interview prompt by template](/images/xm-and-surveys/xm/best-practices/interview-prompt/create-prompt.webp)

View File

@@ -1,41 +1,17 @@
plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-kapt")
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
kotlin("kapt")
}
android {
namespace = "com.formbricks.demo"
compileSdk = 34
packagingOptions {
resources {
excludes += setOf(
"META-INF/DEPENDENCIES",
"META-INF/LICENSE",
"META-INF/LICENSE.txt",
"META-INF/license.txt",
"META-INF/NOTICE",
"META-INF/NOTICE.txt",
"META-INF/notice.txt",
"META-INF/ASL2.0",
"META-INF/*.kotlin_module",
"classes.dex"
)
pickFirsts += setOf(
"**/DataBinderMapperImpl.class",
"**/DataBinderMapperImpl.java",
"**/formbrickssdk/DataBinderMapperImpl.java",
"**/formbrickssdk/DataBinderMapperImpl.class"
)
}
}
compileSdk = 35
defaultConfig {
applicationId = "com.formbricks.demo"
minSdk = 24
targetSdk = 34
targetSdk = 35
versionCode = 1
versionName = "1.0"
@@ -68,8 +44,11 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
implementation(enforcedPlatform("org.jetbrains.kotlin:kotlin-bom:1.7.20"))
}

View File

@@ -3,7 +3,7 @@
xmlns:tools="http://schemas.android.com/tools" >
<application
android:allowBackup="false"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"

View File

@@ -3,18 +3,20 @@ package com.formbricks.demo
import android.os.Bundle
import android.util.Log
import android.widget.Button
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.FragmentActivity
import com.formbricks.formbrickssdk.Formbricks
import com.formbricks.formbrickssdk.FormbricksCallback
import com.formbricks.formbrickssdk.helper.FormbricksConfig
import com.formbricks.formbrickssdk.model.enums.SuccessType
import java.util.UUID
class MainActivity : FragmentActivity() {
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
Formbricks.callback = object: FormbricksCallback {
override fun onSurveyStarted() {

View File

@@ -5,7 +5,10 @@
-->
<data-extraction-rules>
<cloud-backup>
<!-- No specific include/exclude -->
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">192.168.29.120</domain>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.0.200</domain>
<domain includeSubdomains="true">localhost</domain>
</domain-config>
</network-security-config>

View File

@@ -1,15 +1,15 @@
plugins {
id("com.android.library")
id("kotlin-android")
id("kotlin-kapt")
kotlin("plugin.serialization") version "1.7.20"
kotlin("android")
kotlin("kapt")
kotlin("plugin.serialization") version "2.1.0"
id("org.jetbrains.dokka") version "1.9.10"
id("jacoco")
}
android {
namespace = "com.formbricks.formbrickssdk"
compileSdk = 34
compileSdk = 35
defaultConfig {
minSdk = 24
@@ -19,14 +19,35 @@ android {
}
buildTypes {
getByName("debug") {
enableAndroidTestCoverage = true
}
release {
isMinifyEnabled = true
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
packaging {
resources {
excludes += "META-INF/library_release.kotlin_module"
excludes += "classes.dex"
excludes += "**.**"
pickFirsts += "**/DataBinderMapperImpl.java"
pickFirsts += "**/DataBinderMapperImpl.class"
pickFirsts += "**/formbrickssdk/DataBinderMapperImpl.java"
pickFirsts += "**/formbrickssdk/DataBinderMapperImpl.class"
}
}
viewBinding {
enable = true
}
dataBinding {
enable = true
}
buildFeatures {
dataBinding = true
viewBinding = true
@@ -38,29 +59,6 @@ android {
kotlinOptions {
jvmTarget = "11"
}
packagingOptions {
resources {
excludes += setOf(
"META-INF/DEPENDENCIES",
"META-INF/LICENSE",
"META-INF/LICENSE.txt",
"META-INF/license.txt",
"META-INF/NOTICE",
"META-INF/NOTICE.txt",
"META-INF/notice.txt",
"META-INF/ASL2.0",
"META-INF/*.kotlin_module",
"classes.dex"
)
pickFirsts += setOf(
"**/DataBinderMapperImpl.class",
"**/DataBinderMapperImpl.java",
"**/formbrickssdk/DataBinderMapperImpl.java",
"**/formbrickssdk/DataBinderMapperImpl.class"
)
}
}
}
tasks.withType<Test>().configureEach {
@@ -96,5 +94,4 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(project(":formbricksSDK"))
implementation(enforcedPlatform("org.jetbrains.kotlin:kotlin-bom:1.7.20"))
}

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
</manifest>

View File

@@ -34,7 +34,6 @@ object Formbricks {
internal lateinit var appUrl: String
internal var language: String = "default"
internal var loggingEnabled: Boolean = true
internal var autoDismissErrors: Boolean = true
private var fragmentManager: FragmentManager? = null
internal var isInitialized = false
@@ -74,7 +73,7 @@ object Formbricks {
environmentId = config.environmentId
loggingEnabled = config.loggingEnabled
fragmentManager = config.fragmentManager
autoDismissErrors = config.autoDismissErrors
config.userId?.let { UserManager.set(it) }
config.attributes?.let { UserManager.setAttributes(it) }
config.attributes?.get("language")?.let { UserManager.setLanguage(it) }
@@ -101,7 +100,6 @@ object Formbricks {
callback?.onError(error)
Logger.e(error)
return
}
if(UserManager.userId != null) {
@@ -181,7 +179,7 @@ object Formbricks {
* ```
*
*/
fun track(action: String, hiddenFields: Map<String, Any>? = null) {
fun track(action: String) {
if (!isInitialized) {
val error = SDKError.sdkIsNotInitialized
callback?.onError(error)
@@ -196,7 +194,7 @@ object Formbricks {
return
}
SurveyManager.track(action = action, hiddenFields = hiddenFields)
SurveyManager.track(action)
}
/**
@@ -235,7 +233,7 @@ object Formbricks {
}
/// Assembles the survey fragment and presents it
internal fun showSurvey(id: String, hiddenFields: Map<String, Any>? = null) {
internal fun showSurvey(id: String) {
if (fragmentManager == null) {
val error = SDKError.fragmentManagerIsNotSet
callback?.onError(error)
@@ -244,7 +242,7 @@ object Formbricks {
}
fragmentManager?.let {
FormbricksFragment.show(it, surveyId = id, hiddenFields = hiddenFields)
FormbricksFragment.show(it, surveyId = id)
}
}

View File

@@ -15,15 +15,13 @@ class FormbricksConfig private constructor(
val userId: String?,
val attributes: Map<String,String>?,
val loggingEnabled: Boolean,
val fragmentManager: FragmentManager?,
val autoDismissErrors: Boolean = true,
val fragmentManager: FragmentManager?
) {
class Builder(private val appUrl: String, private val environmentId: String) {
private var userId: String? = null
private var attributes: MutableMap<String,String> = mutableMapOf()
private var loggingEnabled = false
private var fragmentManager: FragmentManager? = null
private var autoDismissErrors = true
fun setUserId(userId: String): Builder {
this.userId = userId
@@ -50,11 +48,6 @@ class FormbricksConfig private constructor(
return this
}
fun setAutoDismissErrors(autoDismissErrors: Boolean): Builder {
this.autoDismissErrors = autoDismissErrors
return this
}
fun build(): FormbricksConfig {
return FormbricksConfig(
appUrl = appUrl,
@@ -62,8 +55,7 @@ class FormbricksConfig private constructor(
userId = userId,
attributes = attributes,
loggingEnabled = loggingEnabled,
fragmentManager = fragmentManager,
autoDismissErrors = autoDismissErrors
fragmentManager = fragmentManager
)
}
}

View File

@@ -133,7 +133,7 @@ object SurveyManager {
* Checks if there are any surveys to display, based in the track action, and if so, displays the first one.
* Handles the display percentage and the delay of the survey.
*/
fun track(action: String, hiddenFields: Map<String, Any>? = null) {
fun track(action: String) {
val actionClasses = environmentDataHolder?.data?.data?.actionClasses ?: listOf()
val codeActionClasses = actionClasses.filter { it.type == "code" }
val actionClass = codeActionClasses.firstOrNull { it.key == action }
@@ -171,7 +171,7 @@ object SurveyManager {
stopDisplayTimer()
displayTimer.schedule(object : TimerTask() {
override fun run() {
Formbricks.showSurvey(it, hiddenFields = hiddenFields)
Formbricks.showSurvey(it)
}
}, Date(System.currentTimeMillis() + timeout.toLong() * 1000))
@@ -182,8 +182,12 @@ object SurveyManager {
}
private fun stopDisplayTimer() {
displayTimer.cancel()
displayTimer = Timer()
try {
displayTimer.cancel()
displayTimer = Timer()
} catch (_: Exception) {
}
}
/**

View File

@@ -40,7 +40,7 @@ import java.io.InputStream
import java.util.Timer
class FormbricksFragment(val hiddenFields: Map<String, Any>? = null) : BottomSheetDialogFragment() {
class FormbricksFragment : BottomSheetDialogFragment() {
private lateinit var binding: FragmentFormbricksBinding
private lateinit var surveyId: String
@@ -151,14 +151,17 @@ class FormbricksFragment(val hiddenFields: Map<String, Any>? = null) : BottomShe
dialog?.window?.setDimAmount(0.0f)
binding.formbricksWebview.setBackgroundColor(Color.TRANSPARENT)
binding.formbricksWebview.let {
if (Formbricks.loggingEnabled) {
WebView.setWebContentsDebuggingEnabled(true)
}
it.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
consoleMessage?.let { cm ->
if (cm.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
Formbricks.callback?.onError(SDKError.surveyDisplayFetchError)
if (Formbricks.autoDismissErrors) {
dismiss()
}
dismiss()
}
val log = "[CONSOLE:${cm.messageLevel()}] \"${cm.message()}\", source: ${cm.sourceId()} (${cm.lineNumber()})"
Logger.d(log)
@@ -201,7 +204,7 @@ class FormbricksFragment(val hiddenFields: Map<String, Any>? = null) : BottomShe
it.addJavascriptInterface(webAppInterface, WebAppInterface.INTERFACE_NAME)
}
viewModel.loadHtml(surveyId = surveyId, hiddenFields = hiddenFields)
viewModel.loadHtml(surveyId)
}
private fun getFileName(uri: Uri): String? {
@@ -239,16 +242,12 @@ class FormbricksFragment(val hiddenFields: Map<String, Any>? = null) : BottomShe
companion object {
private val TAG: String by lazy { FormbricksFragment::class.java.simpleName }
fun show(
childFragmentManager: FragmentManager,
surveyId: String,
hiddenFields: Map<String, Any>? = null
) {
val fragment = FormbricksFragment(hiddenFields)
fun show(childFragmentManager: FragmentManager, surveyId: String) {
val fragment = FormbricksFragment()
fragment.surveyId = surveyId
fragment.show(childFragmentManager, TAG)
}
private const val CLOSING_TIMEOUT_IN_SECONDS = 5L
}
}
}

View File

@@ -12,7 +12,6 @@ import com.formbricks.formbrickssdk.model.environment.EnvironmentDataHolder
import com.formbricks.formbrickssdk.model.environment.getProjectStylingJson
import com.formbricks.formbrickssdk.model.environment.getStyling
import com.formbricks.formbrickssdk.model.environment.getSurveyJson
import com.google.gson.Gson
import com.google.gson.JsonObject
/**
@@ -112,22 +111,14 @@ class FormbricksViewModel : ViewModel() {
</html>
"""
fun loadHtml(surveyId: String, hiddenFields: Map<String, Any>? = null) {
fun loadHtml(surveyId: String) {
val environment = SurveyManager.environmentDataHolder.guard { return }
val json = getJson(
environmentDataHolder = environment,
surveyId = surveyId,
hiddenFields = hiddenFields
)
val json = getJson(environment, surveyId)
val htmlString = htmlTemplate.replace("{{WEBVIEW_DATA}}", json)
html.postValue(htmlString)
}
private fun getJson(
environmentDataHolder: EnvironmentDataHolder,
surveyId: String,
hiddenFields: Map<String, Any>? = null
): String {
private fun getJson(environmentDataHolder: EnvironmentDataHolder, surveyId: String): String {
val jsonObject = JsonObject()
environmentDataHolder.getSurveyJson(surveyId).let { jsonObject.add("survey", it) }
jsonObject.addProperty("isBrandingEnabled", true)
@@ -145,7 +136,6 @@ class FormbricksViewModel : ViewModel() {
} else {
jsonObject.addProperty("languageCode", "default")
}
hiddenFields?.let { jsonObject.add("hiddenFieldsRecord", Gson().toJsonTree(it)) }
val hasCustomStyling = environmentDataHolder.data?.data?.surveys?.first { it.id == surveyId }?.styling != null
val enabled = environmentDataHolder.data?.data?.project?.styling?.allowStyleOverwrite ?: false

View File

@@ -39,7 +39,6 @@ class WebAppInterface(private val callback: WebAppCallback?) {
Formbricks.callback?.onError(e)
Logger.e(RuntimeException(e.message))
} catch (e: JsonParseException) {
Formbricks.callback?.onError(e)
Logger.e(RuntimeException("Failed to parse JSON message: $data"))
} catch (e: IllegalArgumentException) {
Formbricks.callback?.onError(e)

View File

@@ -1,29 +1,31 @@
[versions]
agp = "7.2.2"
kotlin = "1.7.20"
coreKtx = "1.8.0"
agp = "8.8.0"
kotlin = "2.0.0"
coreKtx = "1.10.1"
lifecycleRuntimeKtx = "2.6.1"
junit = "1.1.2"
junitVersion = "1.1.3"
espressoCore = "3.3.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
appcompat = "1.2.0"
material = "1.6.1"
appcompat = "1.6.1"
material = "1.10.0"
androidx-annotation = "1.8.0"
kotlinx-serialization-json = "1.4.1"
kotlinx-serialization-json = "1.8.0"
retrofit = "2.9.0"
okhttp3 = "4.11.0"
gson = "2.10.1"
legacySupportV4 = "1.0.0"
lifecycleLivedataKtx = "2.6.1"
lifecycleViewmodelKtx = "2.6.1"
fragmentKtx = "1.3.0"
databindingCommon = "4.1.3"
lifecycleLivedataKtx = "2.8.7"
lifecycleViewmodelKtx = "2.8.7"
fragmentKtx = "1.8.5"
databindingCommon = "8.8.0"
activity = "1.10.1"
constraintlayout = "2.1.4"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -47,6 +49,8 @@ androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecy
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" }
androidx-databinding-common = { group = "androidx.databinding", name = "databinding-common", version.ref = "databindingCommon" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

View File

@@ -1,6 +1,6 @@
#Mon Feb 10 09:17:42 CET 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -1,2 +0,0 @@
# Xcode user-specific UI state
**/xcuserdata/

View File

@@ -1,41 +1,10 @@
import UIKit
import FormbricksSDK
class AppDelegate: NSObject, UIApplicationDelegate, FormbricksDelegate {
func onResponseCreated() {
}
func onSurveyDisplayed() {
}
func onSuccess(_ successAction: FormbricksSDK.SuccessAction) {
}
func application(_ application: UIApplication,
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
Formbricks.delegate = self
return true
}
// MARK: - FormbricksDelegate
func onSurveyStarted() {
print("from the delegate: survey started")
}
func onSurveyFinished() {
print("survey finished")
}
func onSurveyClosed() {
print("survey closed")
}
func onError(_ error: Error) {
print("survey error:", error.localizedDescription)
}
}

View File

@@ -0,0 +1,15 @@
{
"originHash" : "92c0230fb0adc404299bb05aba6c51a76f86c388fdfb9f4e9bed3a757f80fc07",
"pins" : [
{
"identity" : "anycodable",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Flight-School/AnyCodable",
"state" : {
"revision" : "862808b2070cd908cb04f9aafe7de83d35f81b05",
"version" : "0.6.7"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "35F4AE9B-6A79-49B3-AB4C-F05B2141AB6E"
type = "0"
version = "2.0">
</Bucket>

View File

@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
4D7D8DD62DB14F18002C453E /* AnyCodable in Frameworks */ = {isa = PBXBuildFile; productRef = 4D7D8DD52DB14F18002C453E /* AnyCodable */; };
4DDAED692D50D49B00A19B1F /* FormbricksSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4DDAED602D50D49A00A19B1F /* FormbricksSDK.framework */; };
/* End PBXBuildFile section */
@@ -36,6 +37,7 @@
4DDAED9C2D50D54A00A19B1F /* Exceptions for "FormbricksSDK" folder in "FormbricksSDKTests" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Config.swift,
"Extension/Calendar+DaysBetween.swift",
"Extension/Error+Message.swift",
"Extension/JSON+Formatter.swift",
@@ -107,6 +109,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4D7D8DD62DB14F18002C453E /* AnyCodable in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -178,6 +181,7 @@
);
name = FormbricksSDK;
packageProductDependencies = (
4D7D8DD52DB14F18002C453E /* AnyCodable */,
);
productName = FormbricksSDK;
productReference = 4DDAED602D50D49A00A19B1F /* FormbricksSDK.framework */;
@@ -235,6 +239,7 @@
mainGroup = 4DDAED562D50D49A00A19B1F;
minimizedProjectReferenceProxies = 1;
packageReferences = (
4DA4A0952DB14E67007299C0 /* XCRemoteSwiftPackageReference "AnyCodable" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 4DDAED612D50D49A00A19B1F /* Products */;
@@ -545,6 +550,25 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
4DA4A0952DB14E67007299C0 /* XCRemoteSwiftPackageReference "AnyCodable" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Flight-School/AnyCodable";
requirement = {
kind = exactVersion;
version = 0.6.7;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
4D7D8DD52DB14F18002C453E /* AnyCodable */ = {
isa = XCSwiftPackageProductDependency;
package = 4DA4A0952DB14E67007299C0 /* XCRemoteSwiftPackageReference "AnyCodable" */;
productName = AnyCodable;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 4DDAED572D50D49A00A19B1F /* Project object */;
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>FormbricksSDK.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>4DDAED5F2D50D49A00A19B1F</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>4DDAED672D50D49B00A19B1F</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

View File

@@ -1,6 +1,6 @@
import Foundation
public extension Error {
extension Error {
var message: String {
if let error = self as? RuntimeError {
return error.message

View File

@@ -1,88 +1,60 @@
import Foundation
import Network
// Enum representing success actions
public enum SuccessAction: Codable {
case onFinishedSetup
case onFinishedRefreshEnvironment
case onFinishedLogout
case onFinishedSetUserID
case onFoundSurvey
}
/// Formbricks SDK delegate protocol. It contains the main methods to interact with the SDK.
public protocol FormbricksDelegate: AnyObject {
func onResponseCreated()
func onSurveyStarted()
func onSurveyFinished()
func onSurveyClosed()
func onSurveyDisplayed()
func onError(_ error: Error)
func onSuccess (_ successAction: SuccessAction)
}
/// The main class of the Formbricks SDK. It contains the main methods to interact with the SDK.
@objc(Formbricks) public class Formbricks: NSObject {
static internal var appUrl: String?
static internal var environmentId: String?
static internal var language: String = "default"
static internal var isInitialized: Bool = false
static internal var userManager: UserManager?
static internal var presentSurveyManager: PresentSurveyManager?
static internal var surveyManager: SurveyManager?
static internal var apiQueue: OperationQueue? = OperationQueue()
static internal var logger: Logger?
static internal var service = FormbricksService()
public static weak var delegate: FormbricksDelegate?
static internal var securityCertData: Data?
// make this class not instantiatable outside of the SDK
internal override init() {
/*
/*
This empty initializer prevents external instantiation of the Formbricks class.
All methods are static and the class serves as a namespace for the SDK,
so instance creation is not needed and should be restricted.
*/
}
/**
Initializes the Formbricks SDK with the given config ``FormbricksConfig``.
This method is mandatory to be called, and should be only once per application lifecycle.
Example:
```swift
let config = FormbricksConfig.Builder(appUrl: "APP_URL_HERE", environmentId: "TOKEN_HERE")
.setUserId("USER_ID_HERE")
.setLogLevel(.debug)
.build()
Formbricks.setup(with: config)
```
*/
@objc public static func setup(with config: FormbricksConfig,
force: Bool = false,
certData: Data? = nil) {
@objc public static func setup(with config: FormbricksConfig, force: Bool = false) {
logger = Logger()
apiQueue = OperationQueue()
if force {
if (force == true) {
isInitialized = false
}
guard !isInitialized else {
let error = FormbricksSDKError(type: .sdkIsAlreadyInitialized)
delegate?.onError(error)
Formbricks.logger?.error(error.message)
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsAlreadyInitialized).message)
return
}
self.appUrl = config.appUrl
self.environmentId = config.environmentId
self.logger?.logLevel = config.logLevel
self.securityCertData = certData
userManager = UserManager()
if let userId = config.userId {
userManager?.set(userId: userId)
@@ -94,22 +66,21 @@ public protocol FormbricksDelegate: AnyObject {
userManager?.set(language: language)
self.language = language
}
presentSurveyManager = PresentSurveyManager()
surveyManager = SurveyManager.create(userManager: userManager!, presentSurveyManager: presentSurveyManager!)
userManager?.surveyManager = surveyManager
surveyManager?.refreshEnvironmentIfNeeded(force: force,
isInitial: true)
surveyManager?.refreshEnvironmentIfNeeded(force: force)
userManager?.syncUserStateIfNeeded()
self.isInitialized = true
}
/**
Sets the user id for the current user with the given `String`.
The SDK must be initialized before calling this method.
Example:
```swift
Formbricks.setUserId("USER_ID_HERE")
@@ -117,24 +88,22 @@ public protocol FormbricksDelegate: AnyObject {
*/
@objc public static func setUserId(_ userId: String) {
guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
delegate?.onError(error)
Formbricks.logger?.error(error.message)
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
if let existing = userManager?.userId, !existing.isEmpty {
logger?.error("A userId is already set (\"\(existing)\") please call Formbricks.logout() before setting a new one.")
return
}
userManager?.set(userId: userId)
}
/**
Adds an attribute for the current user with the given `String` value and `String` key.
The SDK must be initialized before calling this method.
Example:
```swift
Formbricks.setAttribute("ATTRIBUTE", forKey: "KEY")
@@ -142,19 +111,17 @@ public protocol FormbricksDelegate: AnyObject {
*/
@objc public static func setAttribute(_ attribute: String, forKey key: String) {
guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
delegate?.onError(error)
Formbricks.logger?.error(error.message)
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
userManager?.add(attribute: attribute, forKey: key)
}
/**
Sets the user attributes for the current user with the given `Dictionary` of `String` values and `String` keys.
The SDK must be initialized before calling this method.
Example:
```swift
Formbricks.setAttributes(["KEY", "ATTRIBUTE"])
@@ -162,19 +129,17 @@ public protocol FormbricksDelegate: AnyObject {
*/
@objc public static func setAttributes(_ attributes: [String : String]) {
guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
delegate?.onError(error)
Formbricks.logger?.error(error.message)
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
userManager?.set(attributes: attributes)
}
/**
Sets the language for the current user with the given `String`.
The SDK must be initialized before calling this method.
Example:
```swift
Formbricks.setLanguage("de")
@@ -182,51 +147,47 @@ public protocol FormbricksDelegate: AnyObject {
*/
@objc public static func setLanguage(_ language: String) {
guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
delegate?.onError(error)
Formbricks.logger?.error(error.message)
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
if (Formbricks.language == language) {
return
}
Formbricks.language = language
userManager?.set(language: language)
}
/**
Tracks an action with the given `String`. The SDK will process the action and it will present the survey if any of them can be triggered.
The SDK must be initialized before calling this method.
Example:
```swift
Formbricks.track("button_clicked")
```
*/
@objc public static func track(_ action: String, hiddenFields: [String: Any]? = nil) {
@objc public static func track(_ action: String) {
guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
delegate?.onError(error)
Formbricks.logger?.error(error.message)
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
Formbricks.isInternetAvailabile { available in
if available {
surveyManager?.track(action, hiddenFields: hiddenFields)
surveyManager?.track(action)
} else {
Formbricks.logger?.warning(FormbricksSDKError.init(type: .networkError).message)
}
}
}
/**
Logs out the current user. This will clear the user attributes and the user id.
The SDK must be initialized before calling this method.
Example:
```swift
Formbricks.logout()
@@ -234,16 +195,13 @@ public protocol FormbricksDelegate: AnyObject {
*/
@objc public static func logout() {
guard Formbricks.isInitialized else {
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
delegate?.onError(error)
Formbricks.logger?.error(error.message)
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
userManager?.logout()
Formbricks.delegate?.onSuccess(.onFinishedLogout)
}
/**
Cleans up the SDK. This will clear the user attributes, the user id and the environment state.
The SDK must be initialized before calling this method.
@@ -260,7 +218,7 @@ public protocol FormbricksDelegate: AnyObject {
}
```
*/
@objc public static func cleanup(waitForOperations: Bool = false, completion: (() -> Void)? = nil) {
if waitForOperations, let queue = apiQueue {
DispatchQueue.global(qos: .background).async {

View File

@@ -1,147 +0,0 @@
// https://github.com/Flight-School/AnyCodable/blob/master/Sources/AnyCodable/AnyCodable.swift
import Foundation
/**
A type-erased `Codable` value.
The `AnyCodable` type forwards encoding and decoding responsibilities
to an underlying value, hiding its specific underlying type.
You can encode or decode mixed-type values in dictionaries
and other collections that require `Encodable` or `Decodable` conformance
by declaring their contained type to be `AnyCodable`.
- SeeAlso: `AnyEncodable`
- SeeAlso: `AnyDecodable`
*/
struct AnyCodable: Codable {
public let value: Any
public init<T>(_ value: T?) {
self.value = value ?? ()
}
}
extension AnyCodable: AnyEncodableProtocol, AnyDecodableProtocol {}
extension AnyCodable: Equatable {
public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
switch (lhs.value, rhs.value) {
case is (Void, Void):
return true
case let (lhs as Bool, rhs as Bool):
return lhs == rhs
case let (lhs as Int, rhs as Int):
return lhs == rhs
case let (lhs as Int8, rhs as Int8):
return lhs == rhs
case let (lhs as Int16, rhs as Int16):
return lhs == rhs
case let (lhs as Int32, rhs as Int32):
return lhs == rhs
case let (lhs as Int64, rhs as Int64):
return lhs == rhs
case let (lhs as UInt, rhs as UInt):
return lhs == rhs
case let (lhs as UInt8, rhs as UInt8):
return lhs == rhs
case let (lhs as UInt16, rhs as UInt16):
return lhs == rhs
case let (lhs as UInt32, rhs as UInt32):
return lhs == rhs
case let (lhs as UInt64, rhs as UInt64):
return lhs == rhs
case let (lhs as Float, rhs as Float):
return lhs == rhs
case let (lhs as Double, rhs as Double):
return lhs == rhs
case let (lhs as String, rhs as String):
return lhs == rhs
case let (lhs as [String: AnyCodable], rhs as [String: AnyCodable]):
return lhs == rhs
case let (lhs as [AnyCodable], rhs as [AnyCodable]):
return lhs == rhs
case let (lhs as [String: Any], rhs as [String: Any]):
return NSDictionary(dictionary: lhs) == NSDictionary(dictionary: rhs)
case let (lhs as [Any], rhs as [Any]):
return NSArray(array: lhs) == NSArray(array: rhs)
case is (NSNull, NSNull):
return true
default:
return false
}
}
}
extension AnyCodable: CustomStringConvertible {
public var description: String {
switch value {
case is Void:
return String(describing: nil as Any?)
case let value as CustomStringConvertible:
return value.description
default:
return String(describing: value)
}
}
}
extension AnyCodable: CustomDebugStringConvertible {
public var debugDescription: String {
if let value = value as? CustomDebugStringConvertible {
return "AnyCodable(\(value.debugDescription))"
}
return "AnyCodable(\(description))"
}
}
extension AnyCodable: ExpressibleByNilLiteral {}
extension AnyCodable: ExpressibleByBooleanLiteral {}
extension AnyCodable: ExpressibleByIntegerLiteral {}
extension AnyCodable: ExpressibleByFloatLiteral {}
extension AnyCodable: ExpressibleByStringLiteral {}
extension AnyCodable: ExpressibleByStringInterpolation {}
extension AnyCodable: ExpressibleByArrayLiteral {}
extension AnyCodable: ExpressibleByDictionaryLiteral {}
extension AnyCodable: Hashable {
public func hash(into hasher: inout Hasher) {
switch value {
case let value as Bool:
hasher.combine(value)
case let value as Int:
hasher.combine(value)
case let value as Int8:
hasher.combine(value)
case let value as Int16:
hasher.combine(value)
case let value as Int32:
hasher.combine(value)
case let value as Int64:
hasher.combine(value)
case let value as UInt:
hasher.combine(value)
case let value as UInt8:
hasher.combine(value)
case let value as UInt16:
hasher.combine(value)
case let value as UInt32:
hasher.combine(value)
case let value as UInt64:
hasher.combine(value)
case let value as Float:
hasher.combine(value)
case let value as Double:
hasher.combine(value)
case let value as String:
hasher.combine(value)
case let value as [String: AnyCodable]:
hasher.combine(value)
case let value as [AnyCodable]:
hasher.combine(value)
default:
break
}
}
}

View File

@@ -1,189 +0,0 @@
// https://github.com/Flight-School/AnyCodable/blob/master/Sources/AnyCodable/AnyCodable.swift
#if canImport(Foundation)
import Foundation
#endif
/**
A type-erased `Decodable` value.
The `AnyDecodable` type forwards decoding responsibilities
to an underlying value, hiding its specific underlying type.
You can decode mixed-type values in dictionaries
and other collections that require `Decodable` conformance
by declaring their contained type to be `AnyDecodable`:
let json = """
{
"boolean": true,
"integer": 42,
"double": 3.141592653589793,
"string": "string",
"array": [1, 2, 3],
"nested": {
"a": "alpha",
"b": "bravo",
"c": "charlie"
},
"null": null
}
""".data(using: .utf8)!
let decoder = JSONDecoder()
let dictionary = try! decoder.decode([String: AnyDecodable].self, from: json)
*/
struct AnyDecodable: Decodable {
public let value: Any
public init<T>(_ value: T?) {
self.value = value ?? ()
}
}
@usableFromInline
protocol AnyDecodableProtocol {
var value: Any { get }
init<T>(_ value: T?)
}
extension AnyDecodable: AnyDecodableProtocol {}
extension AnyDecodableProtocol {
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
#if canImport(Foundation)
self.init(NSNull())
#else
self.init(Optional<Self>.none)
#endif
} else if let bool = try? container.decode(Bool.self) {
self.init(bool)
} else if let int = try? container.decode(Int.self) {
self.init(int)
} else if let uint = try? container.decode(UInt.self) {
self.init(uint)
} else if let double = try? container.decode(Double.self) {
self.init(double)
} else if let string = try? container.decode(String.self) {
self.init(string)
} else if let array = try? container.decode([AnyDecodable].self) {
self.init(array.map { $0.value })
} else if let dictionary = try? container.decode([String: AnyDecodable].self) {
self.init(dictionary.mapValues { $0.value })
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyDecodable value cannot be decoded")
}
}
}
extension AnyDecodable: Equatable {
public static func == (lhs: AnyDecodable, rhs: AnyDecodable) -> Bool {
switch (lhs.value, rhs.value) {
#if canImport(Foundation)
case is (NSNull, NSNull), is (Void, Void):
return true
#endif
case let (lhs as Bool, rhs as Bool):
return lhs == rhs
case let (lhs as Int, rhs as Int):
return lhs == rhs
case let (lhs as Int8, rhs as Int8):
return lhs == rhs
case let (lhs as Int16, rhs as Int16):
return lhs == rhs
case let (lhs as Int32, rhs as Int32):
return lhs == rhs
case let (lhs as Int64, rhs as Int64):
return lhs == rhs
case let (lhs as UInt, rhs as UInt):
return lhs == rhs
case let (lhs as UInt8, rhs as UInt8):
return lhs == rhs
case let (lhs as UInt16, rhs as UInt16):
return lhs == rhs
case let (lhs as UInt32, rhs as UInt32):
return lhs == rhs
case let (lhs as UInt64, rhs as UInt64):
return lhs == rhs
case let (lhs as Float, rhs as Float):
return lhs == rhs
case let (lhs as Double, rhs as Double):
return lhs == rhs
case let (lhs as String, rhs as String):
return lhs == rhs
case let (lhs as [String: AnyDecodable], rhs as [String: AnyDecodable]):
return lhs == rhs
case let (lhs as [AnyDecodable], rhs as [AnyDecodable]):
return lhs == rhs
default:
return false
}
}
}
extension AnyDecodable: CustomStringConvertible {
public var description: String {
switch value {
case is Void:
return String(describing: nil as Any?)
case let value as CustomStringConvertible:
return value.description
default:
return String(describing: value)
}
}
}
extension AnyDecodable: CustomDebugStringConvertible {
public var debugDescription: String {
if let value = value as? CustomDebugStringConvertible {
return "AnyDecodable(\(value.debugDescription))"
} else {
return "AnyDecodable(\(description))"
}
}
}
extension AnyDecodable: Hashable {
public func hash(into hasher: inout Hasher) {
switch value {
case let value as Bool:
hasher.combine(value)
case let value as Int:
hasher.combine(value)
case let value as Int8:
hasher.combine(value)
case let value as Int16:
hasher.combine(value)
case let value as Int32:
hasher.combine(value)
case let value as Int64:
hasher.combine(value)
case let value as UInt:
hasher.combine(value)
case let value as UInt8:
hasher.combine(value)
case let value as UInt16:
hasher.combine(value)
case let value as UInt32:
hasher.combine(value)
case let value as UInt64:
hasher.combine(value)
case let value as Float:
hasher.combine(value)
case let value as Double:
hasher.combine(value)
case let value as String:
hasher.combine(value)
case let value as [String: AnyDecodable]:
hasher.combine(value)
case let value as [AnyDecodable]:
hasher.combine(value)
default:
break
}
}
}

View File

@@ -1,292 +0,0 @@
// https://github.com/Flight-School/AnyCodable/blob/master/Sources/AnyCodable/AnyCodable.swift
#if canImport(Foundation)
import Foundation
#endif
/**
A type-erased `Encodable` value.
The `AnyEncodable` type forwards encoding responsibilities
to an underlying value, hiding its specific underlying type.
You can encode mixed-type values in dictionaries
and other collections that require `Encodable` conformance
by declaring their contained type to be `AnyEncodable`:
let dictionary: [String: AnyEncodable] = [
"boolean": true,
"integer": 42,
"double": 3.141592653589793,
"string": "string",
"array": [1, 2, 3],
"nested": [
"a": "alpha",
"b": "bravo",
"c": "charlie"
],
"null": nil
]
let encoder = JSONEncoder()
let json = try! encoder.encode(dictionary)
*/
struct AnyEncodable: Encodable {
public let value: Any
public init<T>(_ value: T?) {
self.value = value ?? ()
}
}
@usableFromInline
protocol AnyEncodableProtocol {
var value: Any { get }
init<T>(_ value: T?)
}
extension AnyEncodable: AnyEncodableProtocol {}
// MARK: - Encodable
extension AnyEncodableProtocol {
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch value {
#if canImport(Foundation)
case is NSNull:
try container.encodeNil()
#endif
case is Void:
try container.encodeNil()
case let bool as Bool:
try container.encode(bool)
case let int as Int:
try container.encode(int)
case let int8 as Int8:
try container.encode(int8)
case let int16 as Int16:
try container.encode(int16)
case let int32 as Int32:
try container.encode(int32)
case let int64 as Int64:
try container.encode(int64)
case let uint as UInt:
try container.encode(uint)
case let uint8 as UInt8:
try container.encode(uint8)
case let uint16 as UInt16:
try container.encode(uint16)
case let uint32 as UInt32:
try container.encode(uint32)
case let uint64 as UInt64:
try container.encode(uint64)
case let float as Float:
try container.encode(float)
case let double as Double:
try container.encode(double)
case let string as String:
try container.encode(string)
#if canImport(Foundation)
case let number as NSNumber:
try encode(nsnumber: number, into: &container)
case let date as Date:
try container.encode(date)
case let url as URL:
try container.encode(url)
#endif
case let array as [Any?]:
try container.encode(array.map { AnyEncodable($0) })
case let dictionary as [String: Any?]:
try container.encode(dictionary.mapValues { AnyEncodable($0) })
case let encodable as Encodable:
try encodable.encode(to: encoder)
default:
let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyEncodable value cannot be encoded")
throw EncodingError.invalidValue(value, context)
}
}
#if canImport(Foundation)
private func encode(nsnumber: NSNumber, into container: inout SingleValueEncodingContainer) throws {
switch Character(Unicode.Scalar(UInt8(nsnumber.objCType.pointee))) {
case "B":
try container.encode(nsnumber.boolValue)
case "c":
try container.encode(nsnumber.int8Value)
case "s":
try container.encode(nsnumber.int16Value)
case "i", "l":
try container.encode(nsnumber.int32Value)
case "q":
try container.encode(nsnumber.int64Value)
case "C":
try container.encode(nsnumber.uint8Value)
case "S":
try container.encode(nsnumber.uint16Value)
case "I", "L":
try container.encode(nsnumber.uint32Value)
case "Q":
try container.encode(nsnumber.uint64Value)
case "f":
try container.encode(nsnumber.floatValue)
case "d":
try container.encode(nsnumber.doubleValue)
default:
let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "NSNumber cannot be encoded because its type is not handled")
throw EncodingError.invalidValue(nsnumber, context)
}
}
#endif
}
extension AnyEncodable: Equatable {
public static func == (lhs: AnyEncodable, rhs: AnyEncodable) -> Bool {
switch (lhs.value, rhs.value) {
case is (Void, Void):
return true
case let (lhs as Bool, rhs as Bool):
return lhs == rhs
case let (lhs as Int, rhs as Int):
return lhs == rhs
case let (lhs as Int8, rhs as Int8):
return lhs == rhs
case let (lhs as Int16, rhs as Int16):
return lhs == rhs
case let (lhs as Int32, rhs as Int32):
return lhs == rhs
case let (lhs as Int64, rhs as Int64):
return lhs == rhs
case let (lhs as UInt, rhs as UInt):
return lhs == rhs
case let (lhs as UInt8, rhs as UInt8):
return lhs == rhs
case let (lhs as UInt16, rhs as UInt16):
return lhs == rhs
case let (lhs as UInt32, rhs as UInt32):
return lhs == rhs
case let (lhs as UInt64, rhs as UInt64):
return lhs == rhs
case let (lhs as Float, rhs as Float):
return lhs == rhs
case let (lhs as Double, rhs as Double):
return lhs == rhs
case let (lhs as String, rhs as String):
return lhs == rhs
case let (lhs as [String: AnyEncodable], rhs as [String: AnyEncodable]):
return lhs == rhs
case let (lhs as [AnyEncodable], rhs as [AnyEncodable]):
return lhs == rhs
default:
return false
}
}
}
extension AnyEncodable: CustomStringConvertible {
public var description: String {
switch value {
case is Void:
return String(describing: nil as Any?)
case let value as CustomStringConvertible:
return value.description
default:
return String(describing: value)
}
}
}
extension AnyEncodable: CustomDebugStringConvertible {
public var debugDescription: String {
if let value = value as? CustomDebugStringConvertible {
return "AnyEncodable(\(value.debugDescription))"
} else {
return "AnyEncodable(\(description))"
}
}
}
extension AnyEncodable: ExpressibleByNilLiteral {}
extension AnyEncodable: ExpressibleByBooleanLiteral {}
extension AnyEncodable: ExpressibleByIntegerLiteral {}
extension AnyEncodable: ExpressibleByFloatLiteral {}
extension AnyEncodable: ExpressibleByStringLiteral {}
extension AnyEncodable: ExpressibleByStringInterpolation {}
extension AnyEncodable: ExpressibleByArrayLiteral {}
extension AnyEncodable: ExpressibleByDictionaryLiteral {}
extension AnyEncodableProtocol {
public init(nilLiteral _: ()) {
self.init(nil as Any?)
}
public init(booleanLiteral value: Bool) {
self.init(value)
}
public init(integerLiteral value: Int) {
self.init(value)
}
public init(floatLiteral value: Double) {
self.init(value)
}
public init(extendedGraphemeClusterLiteral value: String) {
self.init(value)
}
public init(stringLiteral value: String) {
self.init(value)
}
public init(arrayLiteral elements: Any...) {
self.init(elements)
}
public init(dictionaryLiteral elements: (AnyHashable, Any)...) {
self.init([AnyHashable: Any](elements, uniquingKeysWith: { first, _ in first }))
}
}
extension AnyEncodable: Hashable {
public func hash(into hasher: inout Hasher) {
switch value {
case let value as Bool:
hasher.combine(value)
case let value as Int:
hasher.combine(value)
case let value as Int8:
hasher.combine(value)
case let value as Int16:
hasher.combine(value)
case let value as Int32:
hasher.combine(value)
case let value as Int64:
hasher.combine(value)
case let value as UInt:
hasher.combine(value)
case let value as UInt8:
hasher.combine(value)
case let value as UInt16:
hasher.combine(value)
case let value as UInt32:
hasher.combine(value)
case let value as UInt64:
hasher.combine(value)
case let value as Float:
hasher.combine(value)
case let value as Double:
hasher.combine(value)
case let value as String:
hasher.combine(value)
case let value as [String: AnyEncodable]:
hasher.combine(value)
case let value as [AnyEncodable]:
hasher.combine(value)
default:
break
}
}
}

View File

@@ -1,32 +1,10 @@
import Foundation
internal enum FormbricksEnvironment {
/// Only `appUrl` is user-supplied. Crash early if its missing.
fileprivate static var baseApiUrl: String {
guard let url = Formbricks.appUrl else {
fatalError("Formbricks.setup must be called before using the SDK.")
}
return url
}
/// Returns the full surveyscript URL as a String
static var surveyScriptUrlString: String {
let path = "/" + ["js", "surveys.umd.cjs"].joined(separator: "/")
return baseApiUrl + path
}
/// Returns the full environmentfetch URL as a String for the given ID
static var getEnvironmentRequestEndpoint: String {
let path = "/" + ["api", "v2", "client", "{environmentId}", "environment"]
.joined(separator: "/")
return path
}
/// Returns the full post-user URL as a String for the given ID
static var postUserRequestEndpoint: String {
let path = "/" + ["api", "v2", "client", "{environmentId}", "user"]
.joined(separator: "/")
return path
}
class FormbricksEnvironment {
public static let baseApiUrl: String = Formbricks.appUrl ?? "http://localhost:3000"
public static let surveyScriptUrl: String = "\(baseApiUrl)/js/surveys.umd.cjs"
/// Endpoint for getting environment data. Replace {environmentId} with the actual environment ID.
public static let getEnvironmentRequestEndpoint: String = "/api/v2/client/{environmentId}/environment"
/// Endpoint for posting user data. Replace {environmentId} with the actual environment ID.
public static let postUserRequestEndpoint: String = "/api/v2/client/{environmentId}/user"
}

View File

@@ -8,16 +8,16 @@ final class PresentSurveyManager {
The class serves as a namespace for the present method, so instance creation is not needed and should be restricted.
*/
}
/// The view controller that will present the survey window.
private weak var viewController: UIViewController?
/// Present the webview
func present(environmentResponse: EnvironmentResponse, id: String, hiddenFields: [String: Any]? = nil) {
func present(environmentResponse: EnvironmentResponse, id: String) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if let topVC = self.topViewControllerInVeryWindow(){
let view = FormbricksView(viewModel: FormbricksViewModel(environmentResponse: environmentResponse, surveyId: id, hiddenFields: hiddenFields))
if let window = UIApplication.safeKeyWindow {
let view = FormbricksView(viewModel: FormbricksViewModel(environmentResponse: environmentResponse, surveyId: id))
let vc = UIHostingController(rootView: view)
vc.modalPresentationStyle = .overCurrentContext
vc.view.backgroundColor = UIColor.gray.withAlphaComponent(0.6)
@@ -25,55 +25,17 @@ final class PresentSurveyManager {
presentationController.detents = [.large()]
}
self.viewController = vc
topVC.present(vc, animated: true, completion: nil)
window.rootViewController?.present(vc, animated: true, completion: nil)
}
}
}
/// Dismiss the webview
func dismissView() {
viewController?.dismiss(animated: false)
viewController?.dismiss(animated: true)
}
func topViewControllerInVeryWindow(controller: UIViewController? =
UIWindow.key?.rootViewController) -> UIViewController? {
if let navigationController = controller as? UINavigationController {
return topViewControllerInVeryWindow(controller: navigationController.visibleViewController)
}
if let tabController = controller as? UITabBarController, let selected = tabController.selectedViewController {
return topViewControllerInVeryWindow(controller: selected)
}
if let presented = controller?.presentedViewController {
return topViewControllerInVeryWindow(controller: presented)
}
return controller
}
deinit {
Formbricks.logger?.debug("Deinitializing \(self)")
}
}
extension UIWindow {
func dismiss() {
isHidden = true
if #available(iOS 13.0, *) {
windowScene = nil
}
}
static var key: UIWindow? {
if #available(iOS 13.0, *) {
return UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow }
} else {
return UIApplication.shared.keyWindow
}
}
}

View File

@@ -5,16 +5,16 @@ import SwiftUI
final class SurveyManager {
private let userManager: UserManager
private let presentSurveyManager: PresentSurveyManager
private init(userManager: UserManager, presentSurveyManager: PresentSurveyManager) {
self.userManager = userManager
self.presentSurveyManager = presentSurveyManager
}
static func create(userManager: UserManager, presentSurveyManager: PresentSurveyManager) -> SurveyManager {
return SurveyManager(userManager: userManager, presentSurveyManager: presentSurveyManager)
}
private static let environmentResponseObjectKey = "environmentResponseObjectKey"
internal var service = FormbricksService()
private var backingEnvironmentResponse: EnvironmentResponse?
@@ -24,46 +24,42 @@ final class SurveyManager {
internal private(set) var isShowingSurvey: Bool = false
/// Store error state
internal private(set) var hasApiError: Bool = false
/// Fills up the `filteredSurveys` array
func filterSurveys() {
guard let environment = environmentResponse else { return }
guard let surveys = environment.data.data.surveys else { return }
let displays = userManager.displays ?? []
let responses = userManager.responses ?? []
let segments = userManager.segments ?? []
filteredSurveys = filterSurveysBasedOnDisplayType(surveys, displays: displays, responses: responses)
filteredSurveys = filterSurveysBasedOnRecontactDays(filteredSurveys, defaultRecontactDays: environment.data.data.project.recontactDays)
// If we have a user, we do more filtering
if userManager.userId != nil {
if segments.isEmpty {
filteredSurveys = []
return
}
filteredSurveys = filterSurveysBasedOnSegments(filteredSurveys, segments: segments)
}
}
/// Checks if there are any surveys to display, based in the track action, and if so, displays the first one.
/// Handles the display percentage and the delay of the survey.
func track(_ action: String, hiddenFields: [String: Any]? = nil) {
func track(_ action: String) {
guard !isShowingSurvey else { return }
let actionClasses = environmentResponse?.data.data.actionClasses ?? []
let codeActionClasses = actionClasses.filter { $0.type == "code" }
let actionClass = codeActionClasses.first { $0.key == action }
let firstSurveyWithActionClass = filteredSurveys.first { survey in
return survey.triggers?.contains(where: { $0.actionClass?.name == actionClass?.name }) ?? false
}
if (firstSurveyWithActionClass == nil) {
Formbricks.delegate?.onError(FormbricksSDKError(type: .surveyNotFoundError))
}
// Display percentage
let shouldDisplay = shouldDisplayBasedOnPercentage(firstSurveyWithActionClass?.displayPercentage)
let isMultiLangSurvey = firstSurveyWithActionClass?.languages?.count ?? 0 > 1
@@ -75,7 +71,7 @@ final class SurveyManager {
Formbricks.logger?.error("Survey \(survey.name) is not available in language “\(currentLanguage)”. Skipping.")
return
}
Formbricks.language = languageCode
}
@@ -84,8 +80,7 @@ final class SurveyManager {
isShowingSurvey = true
let timeout = firstSurveyWithActionClass?.delay ?? 0
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in
self?.showSurvey(withId: surveyId, hiddenFields: hiddenFields)
Formbricks.delegate?.onSuccess(.onFoundSurvey)
self?.showSurvey(withId: surveyId)
}
}
}
@@ -94,16 +89,13 @@ final class SurveyManager {
// MARK: - API calls -
extension SurveyManager {
/// Checks if the environment state needs to be refreshed based on its `expiresAt` property, and if so, refreshes it, starts the refresh timer, and filters the surveys.
func refreshEnvironmentIfNeeded(force: Bool = false,
isInitial: Bool = false) {
if (!force) {
if let environmentResponse = environmentResponse, environmentResponse.data.expiresAt.timeIntervalSinceNow > 0 {
Formbricks.logger?.debug("Environment state is still valid until \(environmentResponse.data.expiresAt)")
filterSurveys()
return
}
func refreshEnvironmentIfNeeded(force: Bool = false) {
if let environmentResponse = environmentResponse, environmentResponse.data.expiresAt.timeIntervalSinceNow > 0, !force {
Formbricks.logger?.debug("Environment state is still valid until \(environmentResponse.data.expiresAt)")
filterSurveys()
return
}
service.getEnvironmentState { [weak self] result in
switch result {
case .success(let response):
@@ -111,30 +103,22 @@ extension SurveyManager {
self?.environmentResponse = response
self?.startRefreshTimer(expiresAt: response.data.expiresAt)
self?.filterSurveys()
if (isInitial) {
Formbricks.delegate?.onSuccess(.onFinishedSetup)
} else {
Formbricks.delegate?.onSuccess(.onFinishedRefreshEnvironment)
}
case .failure:
self?.hasApiError = true
let error = FormbricksSDKError(type: .unableToRefreshEnvironment)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error.message)
Formbricks.logger?.error(FormbricksSDKError(type: .unableToRefreshEnvironment).message)
self?.startErrorTimer()
}
}
}
/// Posts a survey response to the Formbricks API.
func postResponse(surveyId: String) {
userManager.onResponse(surveyId: surveyId)
}
/// Creates a new display for the survey. It is called when the survey is displayed to the user.
func onNewDisplay(surveyId: String) {
userManager.onDisplay(surveyId: surveyId)
Formbricks.delegate?.onSurveyDisplayed()
}
}
@@ -151,36 +135,36 @@ private extension SurveyManager {
/// Presents the survey window with the given id. It is called when a survey is triggered.
/// The survey is displayed based on the `FormbricksView`.
/// The view controller is presented over the current context.
func showSurvey(withId id: String, hiddenFields: [String: Any]? = nil) {
func showSurvey(withId id: String) {
if let environmentResponse = environmentResponse {
presentSurveyManager.present(environmentResponse: environmentResponse, id: id, hiddenFields: hiddenFields)
presentSurveyManager.present(environmentResponse: environmentResponse, id: id)
}
}
/// Starts a timer to refresh the environment state after the given timeout (`expiresAt`).
func startRefreshTimer(expiresAt: Date) {
let timeout = expiresAt.timeIntervalSinceNow
refreshEnvironmentAfter(timeout: timeout)
}
/// When an error occurs, it starts a timer to refresh the environment state after the given timeout.
func startErrorTimer() {
refreshEnvironmentAfter(timeout: Double(Config.Environment.refreshStateOnErrorTimeoutInMinutes) * 60.0)
}
/// Refreshes the environment state after the given timeout.
func refreshEnvironmentAfter(timeout: Double) {
guard timeout > 0 else {
return
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in
Formbricks.logger?.debug("Refreshing environment state.")
self?.refreshEnvironmentIfNeeded(force: true)
}
}
/// Decides if the survey should be displayed based on the display percentage.
func shouldDisplayBasedOnPercentage(_ displayPercentage: Double?) -> Bool {
guard let displayPercentage = displayPercentage else { return true }
@@ -199,9 +183,7 @@ extension SurveyManager {
if let data = UserDefaults.standard.data(forKey: SurveyManager.environmentResponseObjectKey) {
return try? JSONDecoder().decode(EnvironmentResponse.self, from: data)
} else {
let error = FormbricksSDKError(type: .unableToRetrieveEnvironment)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error.message)
Formbricks.logger?.error(FormbricksSDKError(type: .unableToRetrieveEnvironment).message)
return nil
}
}
@@ -210,9 +192,7 @@ extension SurveyManager {
UserDefaults.standard.set(data, forKey: SurveyManager.environmentResponseObjectKey)
backingEnvironmentResponse = newValue
} else {
let error = FormbricksSDKError(type: .unableToPersistEnvironment)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error.message)
Formbricks.logger?.error(FormbricksSDKError(type: .unableToPersistEnvironment).message)
}
}
}
@@ -226,13 +206,13 @@ private extension SurveyManager {
switch survey.displayOption {
case .respondMultiple:
return true
case .displayOnce:
return !displays.contains { $0.surveyId == survey.id }
case .displayMultiple:
return !responses.contains { $0 == survey.id }
case .displaySome:
if let limit = survey.displayLimit {
if responses.contains(where: { $0 == survey.id }) {
@@ -242,32 +222,30 @@ private extension SurveyManager {
} else {
return true
}
default:
let error = FormbricksSDKError(type: .invalidDisplayOption)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error.message)
Formbricks.logger?.error(FormbricksSDKError(type: .invalidDisplayOption).message)
return false
}
}
}
/// Filters the surveys based on the recontact days and the `lastDisplayedAt` date.
func filterSurveysBasedOnRecontactDays(_ surveys: [Survey], defaultRecontactDays: Int?) -> [Survey] {
surveys.filter { survey in
guard let lastDisplayedAt = userManager.lastDisplayedAt else { return true }
let recontactDays = survey.recontactDays ?? defaultRecontactDays
if let recontactDays = recontactDays {
return Calendar.current.numberOfDaysBetween(Date(), and: lastDisplayedAt) >= recontactDays
}
return true
}
}
func getLanguageCode(
survey: Survey,
language: String?
@@ -275,27 +253,27 @@ private extension SurveyManager {
// 1) Collect all codes
let availableLanguageCodes = survey.languages?
.map { $0.language.code }
// 2) If no language was passed or it's the explicit "default" token default
guard let raw = language?.lowercased(), !raw.isEmpty else {
return "default"
}
if raw == "default" {
return "default"
}
// 3) Find matching entry by code or alias
let selected = survey.languages?.first { entry in
entry.language.code.lowercased() == raw ||
entry.language.alias?.lowercased() == raw
}
// 4) If that entry is marked default default
if selected?.isDefault == true {
return "default"
}
// 5) If no entry, or not enabled, or code not in the available list nil
guard
let entry = selected,
@@ -304,7 +282,7 @@ private extension SurveyManager {
else {
return nil
}
// 6) Otherwise return its code
return entry.language.code
}

View File

@@ -3,11 +3,11 @@ import Foundation
/// Store and manage user state and sync with the server when needed.
final class UserManager: UserManagerSyncable {
weak var surveyManager: SurveyManager?
init(surveyManager: SurveyManager? = nil) {
self.surveyManager = surveyManager
}
private static let userIdKey = "userIdKey"
private static let contactIdKey = "contactIdKey"
private static let segmentsKey = "segmentsKey"
@@ -15,9 +15,9 @@ final class UserManager: UserManagerSyncable {
private static let responsesKey = "responsesKey"
private static let lastDisplayedAtKey = "lastDisplayedAtKey"
private static let expiresAtKey = "expiresAtKey"
internal var service = FormbricksService()
private var backingUserId: String?
private var backingContactId: String?
private var backingSegments: [String]?
@@ -25,33 +25,33 @@ final class UserManager: UserManagerSyncable {
private var backingResponses: [String]?
private var backingLastDisplayedAt: Date?
private var backingExpiresAt: Date?
lazy private var updateQueue: UpdateQueue? = {
return UpdateQueue(userManager: self)
}()
internal var syncTimer: Timer?
/// Starts an update queue with the given user id.
func set(userId: String) {
updateQueue?.set(userId: userId)
}
/// Starts an update queue with the given attribute.
func add(attribute: String, forKey key: String) {
updateQueue?.add(attribute: attribute, forKey: key)
}
/// Starts an update queue with the given attributes.
func set(attributes: [String: String]) {
updateQueue?.set(attributes: attributes)
}
/// Starts an update queue with the given language..
func set(language: String) {
updateQueue?.set(language: language)
}
/// Saves `surveyId` to the `displays` property and the current date to the `lastDisplayedAt` property.
func onDisplay(surveyId: String) {
let lastDisplayedAt = Date()
@@ -61,7 +61,7 @@ final class UserManager: UserManagerSyncable {
self.lastDisplayedAt = lastDisplayedAt
surveyManager?.filterSurveys()
}
/// Saves `surveyId` to the `responses` property.
func onResponse(surveyId: String) {
var newResponses = responses ?? []
@@ -69,7 +69,7 @@ final class UserManager: UserManagerSyncable {
responses = newResponses
surveyManager?.filterSurveys()
}
/// Syncs the user state with the server if the user id is set and the expiration date has passed.
func syncUserStateIfNeeded() {
guard let id = userId, let expiresAt = self.expiresAt, expiresAt.timeIntervalSinceNow <= 0 else {
@@ -78,7 +78,7 @@ final class UserManager: UserManagerSyncable {
backingResponses = []
return
}
syncUser(withId: id)
}
@@ -94,30 +94,29 @@ final class UserManager: UserManagerSyncable {
self?.responses = userResponse.data.state?.data?.responses
self?.lastDisplayedAt = userResponse.data.state?.data?.lastDisplayAt
self?.expiresAt = userResponse.data.state?.expiresAt
let serverLanguage = userResponse.data.state?.data?.language
Formbricks.language = serverLanguage ?? "default"
self?.updateQueue?.reset()
self?.surveyManager?.filterSurveys()
self?.startSyncTimer()
case .failure(let error):
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error)
}
}
}
/// Logs out the user and clears the user state.
func logout() {
var isUserIdDefined = false
if userId != nil {
isUserIdDefined = true
} else {
Formbricks.logger?.error("no userId is set, please set a userId first using the setUserId function")
}
UserDefaults.standard.removeObject(forKey: UserManager.userIdKey)
UserDefaults.standard.removeObject(forKey: UserManager.contactIdKey)
UserDefaults.standard.removeObject(forKey: UserManager.segmentsKey)
@@ -133,22 +132,19 @@ final class UserManager: UserManagerSyncable {
backingLastDisplayedAt = nil
backingExpiresAt = nil
Formbricks.language = "default"
syncTimer?.invalidate()
syncTimer = nil
updateQueue?.cleanup()
updateQueue?.reset()
if isUserIdDefined {
Formbricks.logger?.debug("Successfully logged out user and reset the user state.")
}
}
func cleanupUpdateQueue() {
updateQueue?.cleanup()
updateQueue = nil // Release the instance so memory can be reclaimed.
}
deinit {
Formbricks.logger?.debug("Deinitializing \(self)")
}

View File

@@ -1,6 +1,6 @@
import Foundation
public enum FormbricksAPIErrorType: Int {
enum FormbricksAPIErrorType: Int {
case invalidResponse
case responseError
@@ -14,12 +14,12 @@ public enum FormbricksAPIErrorType: Int {
}
}
public final class FormbricksAPIClientError: LocalizedError {
public let type: FormbricksAPIErrorType
public let statusCodeInt: Int?
final class FormbricksAPIClientError: LocalizedError {
let type: FormbricksAPIErrorType
let statusCodeInt: Int?
let statusCode: HTTPStatusCode?
public var errorDescription: String
var errorDescription: String
init(type: FormbricksAPIErrorType, statusCode: Int? = nil) {
self.type = type

View File

@@ -1,6 +1,6 @@
import Foundation
public enum FormbricksSDKErrorType: Int {
enum FormbricksSDKErrorType: Int {
case sdkIsNotInitialized
case sdkIsAlreadyInitialized
case invalidAppUrl
@@ -8,13 +8,10 @@ public enum FormbricksSDKErrorType: Int {
case unableToPersistEnvironment
case unableToRetrieveEnvironment
case invalidJavascriptMessage
case surveyLibraryLoadError
case unableToRetrieveUser
case unableToPersistUser
case userIdIsNotSetYet
case invalidDisplayOption
case surveyNotFoundError
case surveyNotDisplayableError
case networkError
var description: String {
@@ -41,21 +38,15 @@ public enum FormbricksSDKErrorType: Int {
return "Unable to commit user attributes because userId is not set."
case .invalidDisplayOption:
return "Invalid Display Option"
case .surveyNotFoundError:
return "Survey Not Found"
case .surveyLibraryLoadError:
return "Survey Library Load Error"
case .surveyNotDisplayableError:
return "Survey Not Displayable"
case .networkError:
return "No internet connection"
}
}
}
public final class FormbricksSDKError: LocalizedError {
public let type: FormbricksSDKErrorType
public var errorDescription: String
final class FormbricksSDKError: LocalizedError {
let type: FormbricksSDKErrorType
var errorDescription: String
init(type: FormbricksSDKErrorType) {
self.type = type

View File

@@ -2,68 +2,60 @@ import Foundation
class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
private let session = URLSession.shared
private let request: Request
private let completion: ((ResultType<Request.Response>) -> Void)?
init(request: Request, completion: ((ResultType<Request.Response>) -> Void)?) {
self.request = request
self.completion = completion
}
override func main() {
guard let finalURL = buildFinalURL() else {
completion?(.failure(FormbricksSDKError(type: .sdkIsNotInitialized)))
return
}
let urlRequest = createURLRequest(forURL: finalURL)
logRequest(urlRequest)
let session = URLSession(configuration: URLSessionConfiguration.ephemeral,
delegate: SSLPinningDelegate(),
delegateQueue: nil)
session.dataTask(with: urlRequest) { data, response, error in
defer {
session.finishTasksAndInvalidate()
}
self.processResponse(data: data, response: response, error: error)
}.resume()
}
private func buildFinalURL() -> URL? {
guard let apiURL = request.baseURL, var components = URLComponents(string: apiURL) else { return nil }
components.queryItems = request.queryParams?.map { URLQueryItem(name: $0.key, value: $0.value) }
guard var url = components.url, let path = setPathParams(request.requestEndPoint) else { return nil }
url.appendPathComponent(path)
return url
}
private func processResponse(data: Data?, response: URLResponse?, error: Error?) {
guard let httpStatus = (response as? HTTPURLResponse)?.status else {
let error = FormbricksAPIClientError(type: .invalidResponse)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error("ERROR \(error.message)")
completion?(.failure(error))
return
}
var message = "\(httpStatus.rawValue) <<< \(response?.url?.absoluteString ?? "")"
if httpStatus.responseType == .success {
handleSuccessResponse(data: data, statusCode: httpStatus.rawValue, message: &message)
} else {
handleFailureResponse(data: data, error: error, statusCode: httpStatus.rawValue, message: message)
}
}
private func handleSuccessResponse(data: Data?, statusCode: Int, message: inout String) {
guard let data = data else {
let error = FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode)
Formbricks.delegate?.onError(error)
completion?(.failure(error))
completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode)))
return
}
@@ -88,27 +80,24 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
handleDecodingError(error, message: &message, statusCode: statusCode)
}
}
private func handleFailureResponse(data: Data?, error: Error?, statusCode: Int, message: String) {
var log = message
if let error = error {
log.append("\nError: \(error.localizedDescription)")
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(log)
completion?(.failure(error))
} else if let data = data, let apiError = try? request.decoder.decode(FormbricksAPIError.self, from: data) {
Formbricks.delegate?.onError(apiError)
Formbricks.logger?.error("\(log)\n\(apiError.getDetailedErrorMessage())")
completion?(.failure(apiError))
} else {
let error = FormbricksAPIClientError(type: .responseError, statusCode: statusCode)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error("\(log)\n\(error.message)")
completion?(.failure(error))
}
}
private func handleDecodingError(_ error: Error, message: inout String, statusCode: Int) {
switch error {
case let DecodingError.dataCorrupted(context):
@@ -123,20 +112,17 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
message.append("Error: \(error.localizedDescription)")
}
let error = FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error.message)
completion?(.failure(error))
Formbricks.logger?.error(message)
completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode)))
}
private func logRequest(_ request: URLRequest) {
var message = "\(request.httpMethod ?? "") >>> \(request.url?.absoluteString ?? "")"
if let headers = request.allHTTPHeaderFields {
message.append("\nHeaders: \(headers)")
}
if let body = request.httpBody, let bodyString = String(data: body, encoding: .utf8) {
message.append("\nBody: \(bodyString)")
}
@@ -149,65 +135,31 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
private extension APIClient {
func createURLRequest(forURL url: URL) -> URLRequest {
var urlRequest = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 10)
request.headers?.forEach {
urlRequest.addValue($0.value, forHTTPHeaderField: $0.key)
}
urlRequest.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
urlRequest.httpMethod = request.requestType.rawValue
if let body = request.requestBody {
urlRequest.httpBody = body
}
return urlRequest
}
func setPathParams(_ path: String) -> String? {
var newPath = path
if let environmentId = Formbricks.environmentId {
newPath = newPath.replacingOccurrences(of: "{environmentId}", with: environmentId)
}
request.pathParams?.forEach { key, value in
newPath = newPath.replacingOccurrences(of: key, with: value)
}
return newPath
}
}
class SSLPinningDelegate: NSObject, URLSessionDelegate {
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let securityCertData = Formbricks.securityCertData else {
// No pinning cert available, fallback to default handling
if let serverTrust = challenge.protectionSpace.serverTrust {
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
return
}
guard let serverTrust = challenge.protectionSpace.serverTrust,
let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let serverCertificateData = SecCertificateCopyData(certificate) as Data
if serverCertificateData == securityCertData {
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}

View File

@@ -7,35 +7,35 @@ protocol UserManagerSyncable: AnyObject {
/// Update queue. This class is used to queue updates to the user.
/// The given properties will be sent to the backend and updated in the user object when the debounce interval is reached.
final class UpdateQueue {
private static var debounceInterval: TimeInterval = 0.5
private let syncQueue = DispatchQueue(label: "com.formbricks.updateQueue")
private var userId: String?
private var attributes: [String : String]?
private var language: String?
private var timer: Timer?
private weak var userManager: UserManagerSyncable?
init(userManager: UserManagerSyncable) {
self.userManager = userManager
}
func set(userId: String) {
syncQueue.sync {
self.userId = userId
startDebounceTimer()
}
}
func set(attributes: [String : String]) {
syncQueue.sync {
self.attributes = attributes
startDebounceTimer()
}
}
func add(attribute: String, forKey key: String) {
syncQueue.sync {
if var attr = self.attributes {
@@ -47,14 +47,14 @@ final class UpdateQueue {
startDebounceTimer()
}
}
func set(language: String) {
syncQueue.sync {
self.language = language
// Check if we have an effective userId
let effectiveUserId = self.userId ?? Formbricks.userManager?.userId
if effectiveUserId != nil {
// If we have a userId, set attributes
self.attributes = ["language": language]
@@ -63,11 +63,11 @@ final class UpdateQueue {
Formbricks.logger?.debug("UpdateQueue - updating language locally: \(language)")
return
}
startDebounceTimer()
}
}
func reset() {
syncQueue.sync {
userId = nil
@@ -75,7 +75,7 @@ final class UpdateQueue {
language = nil
}
}
deinit {
Formbricks.logger?.debug("Deinitializing \(self)")
}
@@ -85,7 +85,7 @@ private extension UpdateQueue {
func startDebounceTimer() {
timer?.invalidate()
timer = nil
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.timer = Timer.scheduledTimer(timeInterval: UpdateQueue.debounceInterval,
@@ -95,17 +95,15 @@ private extension UpdateQueue {
repeats: false)
}
}
@objc func commit() {
let effectiveUserId: String? = self.userId ?? Formbricks.userManager?.userId ?? nil
guard let userId = effectiveUserId else {
let error = FormbricksSDKError(type: .userIdIsNotSetYet)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error.message)
Formbricks.logger?.error(FormbricksSDKError(type: .userIdIsNotSetYet).message)
return
}
Formbricks.logger?.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(attributes ?? [:])")
userManager?.syncUser(withId: userId, attributes: attributes)
}

View File

@@ -6,9 +6,9 @@ final class FormbricksViewModel: ObservableObject {
@Published var htmlString: String?
let surveyId: String
init(environmentResponse: EnvironmentResponse, surveyId: String, hiddenFields: [String: Any]? = nil) {
init(environmentResponse: EnvironmentResponse, surveyId: String) {
self.surveyId = surveyId
if let webviewDataJson = WebViewData(environmentResponse: environmentResponse, surveyId: surveyId, hiddenFields: hiddenFields).getJsonString() {
if let webviewDataJson = WebViewData(environmentResponse: environmentResponse, surveyId: surveyId).getJsonString() {
htmlString = htmlTemplate.replacingOccurrences(of: "{{WEBVIEW_DATA}}", with: webviewDataJson)
}
}
@@ -21,7 +21,7 @@ private extension FormbricksViewModel {
<!doctype html>
<html>
<meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0">
<head>
<title>Formbricks WebView Survey</title>
</head>
@@ -41,11 +41,11 @@ private extension FormbricksViewModel {
function onDisplayCreated() {
window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onDisplayCreated" }));
};
function onResponseCreated() {
window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onResponseCreated" }));
};
function onOpenExternalURL(url) {
window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onOpenExternalURL", onOpenExternalURLParams: { url: url } }));
};
@@ -64,7 +64,7 @@ private extension FormbricksViewModel {
}
const script = document.createElement("script");
script.src = "\(FormbricksEnvironment.surveyScriptUrlString)";
script.src = "\(Formbricks.appUrl ?? "http://localhost:3000")/js/surveys.umd.cjs";
script.async = true;
script.onload = () => loadSurvey();
script.onerror = (error) => {
@@ -76,44 +76,40 @@ private extension FormbricksViewModel {
</html>
"""
}
}
// MARK: - Helper class -
private class WebViewData {
var data: [String: Any] = [:]
init(environmentResponse: EnvironmentResponse, surveyId: String, hiddenFields: [String: Any]? = nil) {
init(environmentResponse: EnvironmentResponse, surveyId: String) {
data["survey"] = environmentResponse.getSurveyJson(forSurveyId: surveyId)
data["isBrandingEnabled"] = true
data["appUrl"] = Formbricks.appUrl
data["environmentId"] = Formbricks.environmentId
data["contactId"] = Formbricks.userManager?.contactId
data["isWebEnvironment"] = false
if let hiddenFields = hiddenFields, !hiddenFields.isEmpty {
data["hiddenFieldsRecord"] = hiddenFields
}
let isMultiLangSurvey = environmentResponse.data.data.surveys?.first(where: { $0.id == surveyId })?.languages?.count ?? 0 > 1
if isMultiLangSurvey {
data["languageCode"] = Formbricks.language
} else {
data["languageCode"] = "default"
}
let hasCustomStyling = environmentResponse.data.data.surveys?.first(where: { $0.id == surveyId })?.styling != nil
let enabled = environmentResponse.data.data.project.styling?.allowStyleOverwrite ?? false
data["styling"] = hasCustomStyling && enabled ? environmentResponse.getSurveyStylingJson(forSurveyId: surveyId): environmentResponse.getProjectStylingJson()
}
func getJsonString() -> String? {
do {
let jsonData = try JSONSerialization.data(withJSONObject: data, options: [])
return String(data: jsonData, encoding: .utf8)?.replacingOccurrences(of: "\\\"", with: "'")
} catch {
Formbricks.delegate?.onError(error)
Formbricks.logger?.error(error.message)
return nil
}

View File

@@ -49,7 +49,7 @@ struct SurveyWebView: UIViewRepresentable {
uiView.uiDelegate = nil
Formbricks.logger?.debug("SurveyWebView: Dismantled")
}
/// Clean up cookies and website data.
func clean() {
HTTPCookieStorage.shared.removeCookies(since: Date.distantPast)
@@ -57,7 +57,7 @@ struct SurveyWebView: UIViewRepresentable {
self.remove(records)
}
}
private func remove(_ records: [WKWebsiteDataRecord]) {
records.forEach { record in
WKWebsiteDataStore.default().removeData(ofTypes: record.dataTypes, for: [record], completionHandler: {
@@ -76,8 +76,8 @@ extension SurveyWebView {
// webView function handles Javascipt alert
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
let alertController = UIAlertController(title: "", message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "OK", style: .default) { _ in
/*
alertController.addAction(UIAlertAction(title: "OK", style: .default) { _ in
/*
This closure is intentionally empty since we only need a simple OK button
to dismiss the alert. The alert dismissal is handled automatically by the
system when the button is tapped.
@@ -86,7 +86,7 @@ extension SurveyWebView {
UIApplication.safeKeyWindow?.rootViewController?.presentedViewController?.present(alertController, animated: true)
completionHandler()
}
func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
if let serverTrust = challenge.protectionSpace.serverTrust {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
@@ -94,59 +94,50 @@ extension SurveyWebView {
completionHandler(.useCredential, nil)
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
Formbricks.delegate?.onSurveyDisplayed()
}
}
}
// MARK: - Javascript --> Native message handler -
/// Handle messages coming from the Javascript in the WebView.
final class JsMessageHandler: NSObject, WKScriptMessageHandler {
let surveyId: String
init(surveyId: String) {
self.surveyId = surveyId
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
Formbricks.logger?.debug(message.body)
if let body = message.body as? String, let data = body.data(using: .utf8), let obj = try? JSONDecoder().decode(JsMessageData.self, from: data) {
switch obj.event {
/// Happens when the user submits an answer.
case .onResponseCreated:
Formbricks.surveyManager?.postResponse(surveyId: surveyId)
Formbricks.delegate?.onResponseCreated()
/// Happens when a survey is shown.
case .onDisplayCreated:
Formbricks.delegate?.onSurveyStarted()
Formbricks.surveyManager?.onNewDisplay(surveyId: surveyId)
/// Happens when the user closes the survey view with the close button.
case .onClose:
Formbricks.delegate?.onSurveyClosed()
Formbricks.surveyManager?.dismissSurveyWebView()
/// Happens when the survey wants to open an external link in the default browser.
case .onOpenExternalURL:
if let message = try? JSONDecoder().decode(OpenExternalUrlMessage.self, from: data), let url = URL(string: message.onOpenExternalURLParams.url) {
UIApplication.shared.open(url)
}
/// Happens when the survey library fails to load.
case .onSurveyLibraryLoadError:
Formbricks.surveyManager?.dismissSurveyWebView()
}
} else {
let error = FormbricksSDKError(type: .invalidJavascriptMessage)
Formbricks.delegate?.onError(error)
Formbricks.logger?.error("\(error.message): \(message.body)")
Formbricks.logger?.error("\(FormbricksSDKError(type: .invalidJavascriptMessage).message): \(message.body)")
}
}
}

View File

@@ -1974,6 +1974,7 @@
"alignment_and_engagement_survey_question_1_upper_label": "Vollständiges Verständnis",
"alignment_and_engagement_survey_question_2_headline": "Ich fühle, dass meine Werte mit der Mission und Kultur des Unternehmens übereinstimmen.",
"alignment_and_engagement_survey_question_2_lower_label": "Keine Übereinstimmung",
"alignment_and_engagement_survey_question_2_upper_label": "Vollständige Übereinstimmung",
"alignment_and_engagement_survey_question_3_headline": "Ich arbeite effektiv mit meinem Team zusammen, um unsere Ziele zu erreichen.",
"alignment_and_engagement_survey_question_3_lower_label": "Schlechte Zusammenarbeit",
"alignment_and_engagement_survey_question_3_upper_label": "Ausgezeichnete Zusammenarbeit",
@@ -1983,6 +1984,7 @@
"book_interview": "Interview buchen",
"build_product_roadmap_description": "Finde die EINE Sache heraus, die deine Nutzer am meisten wollen, und baue sie.",
"build_product_roadmap_name": "Produkt Roadmap erstellen",
"build_product_roadmap_name_with_project_name": "$[projectName] Roadmap Ideen",
"build_product_roadmap_question_1_headline": "Wie zufrieden bist Du mit den Funktionen und der Benutzerfreundlichkeit von $[projectName]?",
"build_product_roadmap_question_1_lower_label": "Überhaupt nicht zufrieden",
"build_product_roadmap_question_1_upper_label": "Extrem zufrieden",
@@ -2165,6 +2167,7 @@
"csat_question_7_choice_3": "Etwas schnell",
"csat_question_7_choice_4": "Nicht so schnell",
"csat_question_7_choice_5": "Überhaupt nicht schnell",
"csat_question_7_choice_6": "Nicht zutreffend",
"csat_question_7_headline": "Wie schnell haben wir auf deine Fragen zu unseren Dienstleistungen reagiert?",
"csat_question_7_subheader": "Bitte wähle eine aus:",
"csat_question_8_choice_1": "Das ist mein erster Kauf",
@@ -2172,6 +2175,7 @@
"csat_question_8_choice_3": "Sechs Monate bis ein Jahr",
"csat_question_8_choice_4": "1 - 2 Jahre",
"csat_question_8_choice_5": "3 oder mehr Jahre",
"csat_question_8_choice_6": "Ich habe noch keinen Kauf getätigt",
"csat_question_8_headline": "Wie lange bist Du schon Kunde von $[projectName]?",
"csat_question_8_subheader": "Bitte wähle eine aus:",
"csat_question_9_choice_1": "Sehr wahrscheinlich",
@@ -2386,6 +2390,7 @@
"identify_sign_up_barriers_question_9_dismiss_button_label": "Erstmal überspringen",
"identify_sign_up_barriers_question_9_headline": "Danke! Hier ist dein Code: SIGNUPNOW10",
"identify_sign_up_barriers_question_9_html": "Vielen Dank, dass Du dir die Zeit genommen hast, Feedback zu geben \uD83D\uDE4F",
"identify_sign_up_barriers_with_project_name": "Anmeldebarrieren für $[projectName]",
"identify_upsell_opportunities_description": "Finde heraus, wie viel Zeit dein Produkt deinem Nutzer spart. Nutze dies, um mehr zu verkaufen.",
"identify_upsell_opportunities_name": "Upsell-Möglichkeiten identifizieren",
"identify_upsell_opportunities_question_1_choice_1": "Weniger als 1 Stunde",
@@ -2638,6 +2643,7 @@
"product_market_fit_superhuman_question_3_choice_3": "Produktmanager",
"product_market_fit_superhuman_question_3_choice_4": "People Manager",
"product_market_fit_superhuman_question_3_choice_5": "Softwareentwickler",
"product_market_fit_superhuman_question_3_headline": "Was ist deine Rolle?",
"product_market_fit_superhuman_question_3_subheader": "Bitte wähle eine der folgenden Optionen aus:",
"product_market_fit_superhuman_question_4_headline": "Wer würde am ehesten von $[projectName] profitieren?",
"product_market_fit_superhuman_question_5_headline": "Welchen Mehrwert ziehst Du aus $[projectName]?",
@@ -2659,6 +2665,7 @@
"professional_development_survey_description": "Bewerte die Zufriedenheit der Mitarbeiter mit beruflichen Entwicklungsmöglichkeiten.",
"professional_development_survey_name": "Berufliche Entwicklungsbewertung",
"professional_development_survey_question_1_choice_1": "Ja",
"professional_development_survey_question_1_choice_2": "Nein",
"professional_development_survey_question_1_headline": "Sind Sie an beruflichen Entwicklungsmöglichkeiten interessiert?",
"professional_development_survey_question_2_choice_1": "Networking-Veranstaltungen",
"professional_development_survey_question_2_choice_2": "Konferenzen oder Seminare",
@@ -2748,6 +2755,7 @@
"site_abandonment_survey_question_6_choice_3": "Mehr Produktvielfalt",
"site_abandonment_survey_question_6_choice_4": "Verbesserte Seitengestaltung",
"site_abandonment_survey_question_6_choice_5": "Mehr Kundenbewertungen",
"site_abandonment_survey_question_6_choice_6": "Andere",
"site_abandonment_survey_question_6_headline": "Welche Verbesserungen würden Dich dazu ermutigen, länger auf unserer Seite zu bleiben?",
"site_abandonment_survey_question_6_subheader": "Bitte wähle alle zutreffenden Optionen aus:",
"site_abandonment_survey_question_7_headline": "Möchtest Du Updates über neue Produkte und Aktionen erhalten?",

Some files were not shown because too many files have changed in this diff Show More