Compare commits

...

31 Commits

Author SHA1 Message Date
Dhruwang Jariwala
8bdb818995 fix: server side checks for file upload (#5566)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-30 16:24:54 +00:00
Anshuman Pandey
20466c3800 fix: fixes js-core expiresAt check (#5591) 2025-04-30 14:44:22 +00:00
Matti Nannt
faf6c2d062 chore: migrate react-native to its own repo (#5583) 2025-04-29 22:25:15 +02:00
Piyush Jain
a760a3c341 chore(infra): update karpenter nodepool and add tailscale (#5582) 2025-04-29 18:29:33 +00:00
Matti Nannt
94e6d2f215 chore: remove unused dependencies (#5562) 2025-04-29 17:56:24 +02:00
victorvhs017
a6f1c0f63d chore: Added tests to modules/ee/contacts/segment (#5505)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-29 12:55:35 +00:00
Dhruwang Jariwala
c653996cbb chore: introduced env variable to disable User management UI (#5526) 2025-04-29 11:58:58 +00:00
Dhruwang Jariwala
da44fef89d fix: tolgee config (#5567) 2025-04-29 10:56:37 +00:00
Piyush Jain
4dc2c5e3df chore(networking): add vpc CIDR blocks on database and cluster (#5569) 2025-04-29 08:51:11 +00:00
Piyush Gupta
1797c2ae20 fix: matrix question logic condition text (#5570) 2025-04-29 08:38:26 +00:00
Piyush Jain
3b5da01c0a chore(staging): add release for staging env in formbricks-stage ns (#5486)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-04-29 07:30:28 +00:00
Piyush Gupta
0f1bdce002 feat: advanced matrix question logic (#5408)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-29 05:47:05 +00:00
Matti Nannt
7c8f3e826f chore: relocate locales to apps/web (#5564) 2025-04-28 23:21:48 +02:00
Matti Nannt
f21d63bb55 chore: remove unused changeset action (#5563) 2025-04-28 22:46:05 +02:00
Matti Nannt
f223bb3d3f chore: remove unused langfuse packages (#5561) 2025-04-28 22:00:34 +02:00
Matti Nannt
51001d07b6 chore: remove old AI classification feature (#5529)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-28 19:18:07 +00:00
Jakob Schott
a9eedd3c7a fix: Editing active surveys (#5015)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-28 14:50:25 +00:00
dependabot[bot]
b0aa08fe4e chore(deps): bump docker/login-action from 3.3.0 to 3.4.0 (#5269)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-28 13:12:38 +02:00
Matti Nannt
8d45d24d55 fix: error should not be thrown if SMTP is unconfigured (#5524) 2025-04-28 09:48:14 +02:00
victorvhs017
8c1b9f81b9 chore: Added the tests to file upload summary (#5504)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-28 07:00:46 +00:00
Harsh Bhat
71fad1c22b docs: add swift sdk docs (#5423) 2025-04-27 23:35:21 -07:00
Harsh Bhat
292266c597 docs: add more inbound links (#5424) 2025-04-27 23:35:07 -07:00
Anshuman Pandey
54e589a6a0 fix: surveys package X button hover and modal bg fix (#5518) 2025-04-27 21:44:10 -07:00
Gulshan Kumar
fb3f425c27 fix: Enhances ux in input box in login-page (#5509) 2025-04-27 20:53:55 -07:00
Jakob Schott
1aaa30c6e9 fix: empty headlines; useage of Error as variable and excluded… (#5491)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-25 14:12:31 +00:00
Dhruwang Jariwala
8611410b21 chore: refactored templates file (#5492)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-25 13:34:31 +00:00
victorvhs017
40fa7a69c0 fix: refactor clickable divs into buttons (#5489)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-25 08:13:15 +00:00
Anshuman Pandey
5eca30e513 fix: android sonarqube issues (#5503) 2025-04-25 06:53:39 +00:00
Piyush Gupta
4b78493782 fix: SAML flow jose package import (#5497) 2025-04-25 06:24:59 +00:00
Anshuman Pandey
2ce44b734f fix: stop timers on logout (#5498) 2025-04-24 12:56:28 +00:00
Anshuman Pandey
85d8f8c3ae fix: iOS sonarqube issues (#5494) 2025-04-24 11:19:52 +00:00
357 changed files with 20130 additions and 36074 deletions

View File

@@ -206,12 +206,6 @@ UNKEY_ROOT_KEY=
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
# CUSTOM_CACHE_DISABLED=1
# Azure AI settings
# AI_AZURE_RESSOURCE_NAME=
# AI_AZURE_API_KEY=
# AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID=
# AI_AZURE_LLM_DEPLOYMENT_ID=
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=
@@ -224,3 +218,6 @@ UNKEY_ROOT_KEY=
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
# It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN=
# Disable the user management from UI
# DISABLE_USER_MANAGEMENT

27
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,27 @@
# 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. Do this only when the test file is created.
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
- When using "screen.getByText" check for the tolgee string if it is being used in the 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

@@ -12,6 +12,13 @@ on:
required: false
type: string
default: 'ghcr.io/formbricks/formbricks'
ENVIRONMENT:
description: 'The environment to deploy to'
required: true
type: choice
options:
- stage
- prod
workflow_call:
inputs:
VERSION:
@@ -23,6 +30,10 @@ on:
required: false
type: string
default: 'ghcr.io/formbricks/formbricks'
ENVIRONMENT:
description: 'The environment to deploy to'
required: true
type: string
permissions:
id-token: write
@@ -35,6 +46,13 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Tailscale
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:github
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
@@ -48,6 +66,8 @@ jobs:
AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Prod
if: (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && github.event.inputs.ENVIRONMENT == 'prod'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -58,7 +78,23 @@ jobs:
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets
helmfile-args: apply
helmfile-args: apply -l environment=prod
helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Stage
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ENVIRONMENT == 'stage'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
with:
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets
helmfile-args: apply -l environment=stage
helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm

View File

@@ -31,3 +31,4 @@ jobs:
- helm-chart-release
with:
VERSION: ${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: "prod"

View File

@@ -1,56 +0,0 @@
name: Release Changesets
on:
workflow_dispatch:
#push:
# branches:
# - main
permissions:
contents: write
pull-requests: write
packages: write
concurrency: ${{ github.workflow }}-${{ github.ref }}
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
timeout-minutes: 15
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
with:
egress-policy: audit
- name: Checkout Repo
uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
- name: Setup Node.js 18.x
uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2.5.2
with:
node-version: 18.x
- name: Install pnpm
uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd # v2.2.4
- name: Install Dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@c8bada60c408975afd1a20b3db81d6eee6789308 # v1.4.9
with:
# This expects you to have a script called release which does a build for your packages and calls changeset publish
publish: pnpm release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -51,7 +51,7 @@ jobs:
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}

View File

@@ -71,7 +71,7 @@ jobs:
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}

View File

@@ -33,6 +33,13 @@ jobs:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Tailscale
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:github
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:

View File

@@ -16,6 +16,6 @@ if [ -f branch.json ]; then
echo "Skipping tolgee-pull: NEXT_PUBLIC_TOLGEE_API_KEY is not set"
else
pnpm run tolgee-pull
git add packages/lib/messages
git add apps/web/locales
fi
fi

View File

@@ -4,33 +4,33 @@
"patterns": ["./apps/web/**/*.ts?(x)"],
"projectId": 10304,
"pull": {
"path": "./packages/lib/messages"
"path": "./apps/web/locales"
},
"push": {
"files": [
{
"language": "en-US",
"path": "./packages/lib/messages/en-US.json"
"path": "./apps/web/locales/en-US.json"
},
{
"language": "de-DE",
"path": "./packages/lib/messages/de-DE.json"
"path": "./apps/web/locales/de-DE.json"
},
{
"language": "fr-FR",
"path": "./packages/lib/messages/fr-FR.json"
"path": "./apps/web/locales/fr-FR.json"
},
{
"language": "pt-BR",
"path": "./packages/lib/messages/pt-BR.json"
"path": "./apps/web/locales/pt-BR.json"
},
{
"language": "zh-Hant-TW",
"path": "./packages/lib/messages/zh-Hant-TW.json"
"path": "./apps/web/locales/zh-Hant-TW.json"
},
{
"language": "pt-PT",
"path": "./packages/lib/messages/pt-PT.json"
"path": "./apps/web/locales/pt-PT.json"
}
],
"forceMode": "OVERRIDE"

View File

@@ -1,9 +1,4 @@
{
"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

@@ -3,7 +3,7 @@ Copyright (c) 2024 Formbricks GmbH
Portions of this software are licensed as follows:
- All content that resides under the "apps/web/modules/ee" directory of this repository, if these directories exist, is licensed under the license defined in "apps/web/modules/ee/LICENSE".
- All content that resides under the "packages/js/", "packages/react-native/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
- All content that resides under the "packages/js/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
- All third party components incorporated into the Formbricks Software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.

View File

@@ -1,2 +0,0 @@
EXPO_PUBLIC_APP_URL=http://192.168.0.197:3000
EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=cm5p0cs7r000819182b32j0a1

View File

@@ -1,7 +0,0 @@
module.exports = {
extends: ["@formbricks/eslint-config/react.js"],
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
};

View File

@@ -1,35 +0,0 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo

View File

@@ -1,35 +0,0 @@
{
"expo": {
"android": {
"adaptiveIcon": {
"backgroundColor": "#ffffff",
"foregroundImage": "./assets/adaptive-icon.png"
}
},
"assetBundlePatterns": ["**/*"],
"icon": "./assets/icon.png",
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "Take pictures for certain activities.",
"NSMicrophoneUsageDescription": "Need microphone access for recording videos.",
"NSPhotoLibraryUsageDescription": "Select pictures for certain activities."
},
"supportsTablet": true
},
"jsEngine": "hermes",
"name": "react-native-demo",
"newArchEnabled": true,
"orientation": "portrait",
"slug": "react-native-demo",
"splash": {
"backgroundColor": "#ffffff",
"image": "./assets/splash.png",
"resizeMode": "contain"
},
"userInterfaceStyle": "light",
"version": "1.0.0",
"web": {
"favicon": "./assets/favicon.png"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -1,6 +0,0 @@
module.exports = function babel(api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
};
};

View File

@@ -1,7 +0,0 @@
import { registerRootComponent } from "expo";
import { LogBox } from "react-native";
import App from "./src/app";
registerRootComponent(App);
LogBox.ignoreAllLogs();

View File

@@ -1,21 +0,0 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const path = require("node:path");
const { getDefaultConfig } = require("expo/metro-config");
// Find the workspace root, this can be replaced with `find-yarn-workspace-root`
const workspaceRoot = path.resolve(__dirname, "../..");
const projectRoot = __dirname;
const config = getDefaultConfig(projectRoot);
// 1. Watch all files within the monorepo
config.watchFolders = [workspaceRoot];
// 2. Let Metro know where to resolve packages, and in what order
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
config.resolver.disableHierarchicalLookup = true;
module.exports = config;

View File

@@ -1,30 +0,0 @@
{
"name": "@formbricks/demo-react-native",
"version": "1.0.0",
"main": "./index.js",
"scripts": {
"dev": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"eject": "expo eject",
"clean": "rimraf .turbo node_modules .expo"
},
"dependencies": {
"@formbricks/js": "workspace:*",
"@formbricks/react-native": "workspace:*",
"@react-native-async-storage/async-storage": "2.1.0",
"expo": "52.0.28",
"expo-status-bar": "2.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.6",
"react-native-webview": "13.12.5"
},
"devDependencies": {
"@babel/core": "7.26.0",
"@types/react": "18.3.18",
"typescript": "5.7.2"
},
"private": true
}

View File

@@ -1,117 +0,0 @@
import { StatusBar } from "expo-status-bar";
import React, { type JSX } from "react";
import { Button, LogBox, StyleSheet, Text, View } from "react-native";
import Formbricks, {
logout,
setAttribute,
setAttributes,
setLanguage,
setUserId,
track,
} from "@formbricks/react-native";
LogBox.ignoreAllLogs();
export default function App(): JSX.Element {
if (!process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID) {
throw new Error("EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID is required");
}
if (!process.env.EXPO_PUBLIC_APP_URL) {
throw new Error("EXPO_PUBLIC_APP_URL is required");
}
return (
<View style={styles.container}>
<Text>Formbricks React Native SDK Demo</Text>
<View
style={{
display: "flex",
flexDirection: "column",
gap: 10,
}}>
<Button
title="Trigger Code Action"
onPress={() => {
track("code").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error tracking event:", error);
});
}}
/>
<Button
title="Set User Id"
onPress={() => {
setUserId("random-user-id").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting user id:", error);
});
}}
/>
<Button
title="Set User Attributess (multiple)"
onPress={() => {
setAttributes({
testAttr: "attr-test",
testAttr2: "attr-test-2",
testAttr3: "attr-test-3",
testAttr4: "attr-test-4",
}).catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting user attributes:", error);
});
}}
/>
<Button
title="Set User Attributes (single)"
onPress={() => {
setAttribute("testSingleAttr", "testSingleAttr").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting user attributes:", error);
});
}}
/>
<Button
title="Logout"
onPress={() => {
logout().catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error logging out:", error);
});
}}
/>
<Button
title="Set Language (de)"
onPress={() => {
setLanguage("de").catch((error: unknown) => {
// eslint-disable-next-line no-console -- logging is allowed in demo apps
console.error("Error setting language:", error);
});
}}
/>
</View>
<StatusBar style="auto" />
<Formbricks
appUrl={process.env.EXPO_PUBLIC_APP_URL as string}
environmentId={process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID as string}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
alignItems: "center",
justifyContent: "center",
},
});

View File

@@ -1,6 +0,0 @@
{
"compilerOptions": {
"strict": true
},
"extends": "expo/tsconfig.base"
}

View File

@@ -3,13 +3,13 @@ module.exports = {
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
overrides: [
{
files: ["lib/messages/**/*.json"],
files: ["locales/*.json"],
plugins: ["i18n-json"],
rules: {
"i18n-json/identical-keys": [
"error",
{
filePath: require("path").join(__dirname, "messages", "en-US.json"),
filePath: require("path").join(__dirname, "locales", "en-US.json"),
checkExtraKeys: false,
checkMissingKeys: true,
},

View File

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

View File

@@ -0,0 +1,151 @@
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, idx) => (
<div key={idx} className={`col-span-2 hidden text-center sm:block`}>
{tableHeaders.map((header) => (
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
{t(header)}
</div>
))}
</div>
{integrationData.map((data, index) => (
<div
key={index}
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg hover:bg-slate-100"
<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"
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>
</div>
</button>
))}
</div>
) : (

View File

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

View File

@@ -0,0 +1,91 @@
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);
const integrationArray = notionIntegration
? notionIntegration.config.data
? notionIntegration.config.data
: []
: [];
let integrationArray: TIntegrationNotionConfigData[] = [];
if (notionIntegration?.config.data) {
integrationArray = notionIntegration.config.data;
}
const handleDeleteIntegration = async () => {
setisDeleting(true);
@@ -121,9 +121,9 @@ export const ManageIntegration = ({
{integrationArray &&
integrationArray.map((data, index) => {
return (
<div
key={index}
className="m-2 grid h-16 cursor-pointer grid-cols-6 content-center rounded-lg hover:bg-slate-100"
<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"
onClick={() => {
editIntegration(index);
}}>
@@ -132,7 +132,7 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</div>
</button>
);
})}
</div>

View File

@@ -0,0 +1,158 @@
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,8 +6,7 @@ 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 { useTranslate } from "@tolgee/react";
import { T } from "@tolgee/react";
import { T, useTranslate } from "@tolgee/react";
import { Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
@@ -43,11 +42,10 @@ export const ManageIntegration = ({
const { t } = useTranslate();
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [isDeleting, setisDeleting] = useState(false);
const integrationArray = slackIntegration
? slackIntegration.config.data
? slackIntegration.config.data
: []
: [];
let integrationArray: TIntegrationSlackConfigData[] = [];
if (slackIntegration?.config.data) {
integrationArray = slackIntegration.config.data;
}
const handleDeleteIntegration = async () => {
setisDeleting(true);
@@ -129,9 +127,9 @@ export const ManageIntegration = ({
{integrationArray &&
integrationArray.map((data, index) => {
return (
<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"
<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"
onClick={() => {
editIntegration(index);
}}>
@@ -141,7 +139,7 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</div>
</button>
);
})}
</div>

View File

@@ -58,11 +58,6 @@ const Page = async (props) => {
comingSoon: false,
onRequest: false,
},
{
title: t("environments.settings.enterprise.ai"),
comingSoon: false,
onRequest: true,
},
{
title: t("environments.settings.enterprise.audit_logs"),
comingSoon: false,

View File

@@ -1,96 +0,0 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateOrganizationAIEnabledAction } from "@/modules/ee/insights/actions";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { TOrganization } from "@formbricks/types/organizations";
interface AIToggleProps {
environmentId: string;
organization: TOrganization;
isOwnerOrManager: boolean;
}
export const AIToggle = ({ organization, isOwnerOrManager }: AIToggleProps) => {
const { t } = useTranslate();
const [isAIEnabled, setIsAIEnabled] = useState(organization.isAIEnabled);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleUpdateOrganization = async (data) => {
try {
setIsAIEnabled(data.enabled);
setIsSubmitting(true);
const updatedOrganizationResponse = await updateOrganizationAIEnabledAction({
organizationId: organization.id,
data: {
isAIEnabled: data.enabled,
},
});
if (updatedOrganizationResponse?.data) {
if (data.enabled) {
toast.success(t("environments.settings.general.formbricks_ai_enable_success_message"));
} else {
toast.success(t("environments.settings.general.formbricks_ai_disable_success_message"));
}
} else {
const errorMessage = getFormattedErrorMessage(updatedOrganizationResponse);
toast.error(errorMessage);
}
} catch (err) {
toast.error(`Error: ${err.message}`);
} finally {
setIsSubmitting(false);
if (typeof window !== "undefined") {
setTimeout(() => {
window.location.reload();
}, 500);
}
}
};
return (
<>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Label htmlFor="formbricks-ai-toggle" className="cursor-pointer">
{t("environments.settings.general.enable_formbricks_ai")}
</Label>
<Switch
id="formbricks-ai-toggle"
disabled={!isOwnerOrManager || isSubmitting}
checked={isAIEnabled}
onClick={(e) => {
e.stopPropagation();
handleUpdateOrganization({ enabled: !organization.isAIEnabled });
}}
/>
</div>
<div className="mt-3 text-xs text-slate-600">
{t("environments.settings.general.formbricks_ai_privacy_policy_text")}{" "}
<Link
className="underline"
href={"https://formbricks.com/privacy-policy"}
rel="noreferrer"
target="_blank">
{t("common.privacy_policy")}
</Link>
.
</div>
</div>
{!isOwnerOrManager && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("environments.settings.general.only_org_owner_can_perform_action")}
</AlertDescription>
</Alert>
)}
</>
);
};

View File

@@ -1,9 +1,5 @@
import { getUser } from "@/lib/user/service";
import {
getIsMultiOrgEnabled,
getIsOrganizationAIReady,
getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { getTranslate } from "@/tolgee/server";
@@ -33,12 +29,6 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
AI_AZURE_LLM_API_KEY: "mock-ai",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
}));
vi.mock("next-auth", () => ({
@@ -59,7 +49,6 @@ vi.mock("@/modules/environments/lib/utils", () => ({
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(),
getIsOrganizationAIReady: vi.fn(),
getWhiteLabelPermission: vi.fn(),
}));
@@ -80,7 +69,6 @@ describe("Page", () => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getIsOrganizationAIReady).mockResolvedValue(true);
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
});

View File

@@ -1,12 +1,7 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import {
getIsMultiOrgEnabled,
getIsOrganizationAIReady,
getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
@@ -35,8 +30,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const isOwnerOrManager = isManager || isOwner;
const isOrganizationAIReady = await getIsOrganizationAIReady(organization.billing.plan);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
@@ -56,17 +49,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
membershipRole={currentUserMembership?.role}
/>
</SettingsCard>
{isOrganizationAIReady && (
<SettingsCard
title={t("environments.settings.general.formbricks_ai")}
description={t("environments.settings.general.formbricks_ai_description")}>
<AIToggle
environmentId={params.environmentId}
organization={organization}
isOwnerOrManager={isOwnerOrManager}
/>
</SettingsCard>
)}
<EmailCustomizationSettings
organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission}

View File

@@ -1,6 +1,5 @@
"use server";
import { generateInsightsForSurvey } from "@/app/api/(internal)/insights/lib/utils";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
@@ -108,31 +107,3 @@ export const getResponseCountAction = authenticatedActionClient
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
});
const ZGenerateInsightsForSurveyAction = z.object({
surveyId: ZId,
});
export const generateInsightsForSurveyAction = authenticatedActionClient
.schema(ZGenerateInsightsForSurveyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
schema: ZGenerateInsightsForSurveyAction,
data: parsedInput,
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
generateInsightsForSurvey(parsedInput.surveyId);
});

View File

@@ -0,0 +1,165 @@
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,11 +35,13 @@ export const ResponseTableCell = ({
// Conditional rendering of maximize icon
const renderMaximizeIcon = cell.column.id === "createdAt" && (
<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"
<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"
onClick={handleCellClick}>
<Maximize2Icon className="h-4 w-4" />
</div>
</button>
);
return (

View File

@@ -1,16 +1,13 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { MAX_RESPONSES_FOR_INSIGHT_GENERATION, RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -20,7 +17,7 @@ const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const survey = await getSurvey(params.surveyId);
@@ -38,11 +35,6 @@ const Page = async (props) => {
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
const isAIEnabled = await getIsAIEnabled({
isAIEnabled: organization.isAIEnabled,
billing: organization.billing,
});
const shouldGenerateInsights = needsInsightsGeneration(survey);
const locale = await findMatchingLocale();
const surveyDomain = getSurveyDomain();
@@ -57,16 +49,9 @@ const Page = async (props) => {
isReadOnly={isReadOnly}
user={user}
surveyDomain={surveyDomain}
responseCount={totalResponseCount}
/>
}>
{isAIEnabled && shouldGenerateInsights && (
<EnableInsightsBanner
surveyId={survey.id}
surveyResponseCount={totalResponseCount}
maxResponseCount={MAX_RESPONSES_FOR_INSIGHT_GENERATION}
/>
)}
<SurveyAnalysisNavigation
environmentId={environment.id}
survey={survey}

View File

@@ -0,0 +1,154 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys/types";
import { AddressSummary } from "./AddressSummary";
// Mock dependencies
vi.mock("@/lib/time", () => ({
timeSince: () => "2 hours ago",
}));
vi.mock("@/lib/utils/contact", () => ({
getContactIdentifier: () => "contact@example.com",
}));
vi.mock("@/modules/ui/components/avatars", () => ({
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
}));
vi.mock("@/modules/ui/components/array-response", () => ({
ArrayResponse: ({ value }: { value: string[] }) => (
<div data-testid="array-response">{value.join(", ")}</div>
),
}));
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
}));
describe("AddressSummary", () => {
afterEach(() => {
cleanup();
});
const environmentId = "env-123";
const survey = {} as TSurvey;
const locale = "en-US";
test("renders table headers correctly", () => {
const questionSummary = {
question: { id: "q1", headline: "Address Question" },
samples: [],
} as unknown as TSurveyQuestionSummaryAddress;
render(
<AddressSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
expect(screen.getByText("common.user")).toBeInTheDocument();
expect(screen.getByText("common.response")).toBeInTheDocument();
expect(screen.getByText("common.time")).toBeInTheDocument();
});
test("renders contact information correctly", () => {
const questionSummary = {
question: { id: "q1", headline: "Address Question" },
samples: [
{
id: "response1",
value: ["123 Main St", "Apt 4", "New York", "NY", "10001"],
updatedAt: new Date().toISOString(),
contact: { id: "contact1" },
contactAttributes: { email: "user@example.com" },
},
],
} as unknown as TSurveyQuestionSummaryAddress;
render(
<AddressSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1");
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
expect(screen.getByTestId("array-response")).toHaveTextContent("123 Main St, Apt 4, New York, NY, 10001");
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
// Check link to contact
const contactLink = screen.getByText("contact@example.com").closest("a");
expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/contact1`);
});
test("renders anonymous user when no contact is provided", () => {
const questionSummary = {
question: { id: "q1", headline: "Address Question" },
samples: [
{
id: "response2",
value: ["456 Oak St", "London", "UK"],
updatedAt: new Date().toISOString(),
contact: null,
contactAttributes: {},
},
],
} as unknown as TSurveyQuestionSummaryAddress;
render(
<AddressSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous");
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
expect(screen.getByTestId("array-response")).toHaveTextContent("456 Oak St, London, UK");
});
test("renders multiple responses correctly", () => {
const questionSummary = {
question: { id: "q1", headline: "Address Question" },
samples: [
{
id: "response1",
value: ["123 Main St", "New York"],
updatedAt: new Date().toISOString(),
contact: { id: "contact1" },
contactAttributes: {},
},
{
id: "response2",
value: ["456 Oak St", "London"],
updatedAt: new Date().toISOString(),
contact: { id: "contact2" },
contactAttributes: {},
},
],
} as unknown as TSurveyQuestionSummaryAddress;
render(
<AddressSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getAllByTestId("person-avatar")).toHaveLength(2);
expect(screen.getAllByTestId("array-response")).toHaveLength(2);
expect(screen.getAllByText("2 hours ago")).toHaveLength(2);
});
});

View File

@@ -0,0 +1,89 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionSummaryCta } from "@formbricks/types/surveys/types";
import { CTASummary } from "./CTASummary";
vi.mock("@/modules/ui/components/progress-bar", () => ({
ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => (
<div data-testid="progress-bar">{`${progress}-${barColor}`}</div>
),
}));
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: ({
additionalInfo,
}: {
showResponses: boolean;
additionalInfo: React.ReactNode;
}) => <div data-testid="question-summary-header">{additionalInfo}</div>,
}));
vi.mock("lucide-react", () => ({
InboxIcon: () => <div data-testid="inbox-icon" />,
}));
vi.mock("../lib/utils", () => ({
convertFloatToNDecimal: (value: number) => value.toFixed(2),
}));
describe("CTASummary", () => {
afterEach(() => {
cleanup();
});
const survey = {} as TSurvey;
test("renders with all metrics and required question", () => {
const questionSummary = {
question: { id: "q1", headline: "CTA Question", required: true },
impressionCount: 100,
clickCount: 25,
skipCount: 10,
ctr: { count: 25, percentage: 25 },
} as unknown as TSurveyQuestionSummaryCta;
render(<CTASummary questionSummary={questionSummary} survey={survey} />);
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
expect(screen.getByText("100 common.impressions")).toBeInTheDocument();
// Use getAllByText instead of getByText for multiple matching elements
expect(screen.getAllByText("25 common.clicks")).toHaveLength(2);
expect(screen.queryByText("10 common.skips")).not.toBeInTheDocument(); // Should not show skips for required questions
// Check CTR section
expect(screen.getByText("CTR")).toBeInTheDocument();
expect(screen.getByText("25.00%")).toBeInTheDocument();
// Check progress bar
expect(screen.getByTestId("progress-bar")).toHaveTextContent("0.25-bg-brand-dark");
});
test("renders skip count for non-required questions", () => {
const questionSummary = {
question: { id: "q1", headline: "CTA Question", required: false },
impressionCount: 100,
clickCount: 20,
skipCount: 30,
ctr: { count: 20, percentage: 20 },
} as unknown as TSurveyQuestionSummaryCta;
render(<CTASummary questionSummary={questionSummary} survey={survey} />);
expect(screen.getByText("30 common.skips")).toBeInTheDocument();
});
test("renders singular form for count = 1", () => {
const questionSummary = {
question: { id: "q1", headline: "CTA Question", required: true },
impressionCount: 10,
clickCount: 1,
skipCount: 0,
ctr: { count: 1, percentage: 10 },
} as unknown as TSurveyQuestionSummaryCta;
render(<CTASummary questionSummary={questionSummary} survey={survey} />);
// Use getAllByText instead of getByText for multiple matching elements
expect(screen.getAllByText("1 common.click")).toHaveLength(1);
});
});

View File

@@ -0,0 +1,69 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionSummaryCal } from "@formbricks/types/surveys/types";
import { CalSummary } from "./CalSummary";
vi.mock("@/modules/ui/components/progress-bar", () => ({
ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => (
<div data-testid="progress-bar">{`${progress}-${barColor}`}</div>
),
}));
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
}));
vi.mock("../lib/utils", () => ({
convertFloatToNDecimal: (value: number) => value.toFixed(2),
}));
describe("CalSummary", () => {
afterEach(() => {
cleanup();
});
const environmentId = "env-123";
const survey = {} as TSurvey;
test("renders the correct components and data", () => {
const questionSummary = {
question: { id: "q1", headline: "Calendar Question" },
booked: { count: 5, percentage: 75 },
skipped: { count: 1, percentage: 25 },
} as unknown as TSurveyQuestionSummaryCal;
render(<CalSummary questionSummary={questionSummary} environmentId={environmentId} survey={survey} />);
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
// Check if booked section is displayed
expect(screen.getByText("common.booked")).toBeInTheDocument();
expect(screen.getByText("75.00%")).toBeInTheDocument();
expect(screen.getByText("5 common.responses")).toBeInTheDocument();
// Check if skipped section is displayed
expect(screen.getByText("common.dismissed")).toBeInTheDocument();
expect(screen.getByText("25.00%")).toBeInTheDocument();
expect(screen.getByText("1 common.response")).toBeInTheDocument();
// Check progress bars
const progressBars = screen.getAllByTestId("progress-bar");
expect(progressBars).toHaveLength(2);
expect(progressBars[0]).toHaveTextContent("0.75-bg-brand-dark");
expect(progressBars[1]).toHaveTextContent("0.25-bg-brand-dark");
});
test("renders singular and plural response counts correctly", () => {
const questionSummary = {
question: { id: "q1", headline: "Calendar Question" },
booked: { count: 1, percentage: 50 },
skipped: { count: 1, percentage: 50 },
} as unknown as TSurveyQuestionSummaryCal;
render(<CalSummary questionSummary={questionSummary} environmentId={environmentId} survey={survey} />);
// Use getAllByText directly since we know there are multiple matching elements
const responseElements = screen.getAllByText("1 common.response");
expect(responseElements).toHaveLength(2);
});
});

View File

@@ -0,0 +1,80 @@
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 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{summaryItems.map((summaryItem) => {
return (
<div
className="group cursor-pointer"
<button
className="group w-full 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>
</div>
</button>
);
})}
</div>

View File

@@ -0,0 +1,153 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types";
import { ContactInfoSummary } from "./ContactInfoSummary";
vi.mock("@/lib/time", () => ({
timeSince: () => "2 hours ago",
}));
vi.mock("@/lib/utils/contact", () => ({
getContactIdentifier: () => "contact@example.com",
}));
vi.mock("@/modules/ui/components/avatars", () => ({
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
}));
vi.mock("@/modules/ui/components/array-response", () => ({
ArrayResponse: ({ value }: { value: string[] }) => (
<div data-testid="array-response">{value.join(", ")}</div>
),
}));
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
}));
describe("ContactInfoSummary", () => {
afterEach(() => {
cleanup();
});
const environmentId = "env-123";
const survey = {} as TSurvey;
const locale = "en-US";
test("renders table headers correctly", () => {
const questionSummary = {
question: { id: "q1", headline: "Contact Info Question" },
samples: [],
} as unknown as TSurveyQuestionSummaryContactInfo;
render(
<ContactInfoSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
expect(screen.getByText("common.user")).toBeInTheDocument();
expect(screen.getByText("common.response")).toBeInTheDocument();
expect(screen.getByText("common.time")).toBeInTheDocument();
});
test("renders contact information correctly", () => {
const questionSummary = {
question: { id: "q1", headline: "Contact Info Question" },
samples: [
{
id: "response1",
value: ["John Doe", "john@example.com", "+1234567890"],
updatedAt: new Date().toISOString(),
contact: { id: "contact1" },
contactAttributes: { email: "user@example.com" },
},
],
} as unknown as TSurveyQuestionSummaryContactInfo;
render(
<ContactInfoSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1");
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
expect(screen.getByTestId("array-response")).toHaveTextContent("John Doe, john@example.com, +1234567890");
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
// Check link to contact
const contactLink = screen.getByText("contact@example.com").closest("a");
expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/contact1`);
});
test("renders anonymous user when no contact is provided", () => {
const questionSummary = {
question: { id: "q1", headline: "Contact Info Question" },
samples: [
{
id: "response2",
value: ["Anonymous User", "anonymous@example.com"],
updatedAt: new Date().toISOString(),
contact: null,
contactAttributes: {},
},
],
} as unknown as TSurveyQuestionSummaryContactInfo;
render(
<ContactInfoSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous");
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
expect(screen.getByTestId("array-response")).toHaveTextContent("Anonymous User, anonymous@example.com");
});
test("renders multiple responses correctly", () => {
const questionSummary = {
question: { id: "q1", headline: "Contact Info Question" },
samples: [
{
id: "response1",
value: ["John Doe", "john@example.com"],
updatedAt: new Date().toISOString(),
contact: { id: "contact1" },
contactAttributes: {},
},
{
id: "response2",
value: ["Jane Smith", "jane@example.com"],
updatedAt: new Date().toISOString(),
contact: { id: "contact2" },
contactAttributes: {},
},
],
} as unknown as TSurveyQuestionSummaryContactInfo;
render(
<ContactInfoSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getAllByTestId("person-avatar")).toHaveLength(2);
expect(screen.getAllByTestId("array-response")).toHaveLength(2);
expect(screen.getAllByText("2 hours ago")).toHaveLength(2);
});
});

View File

@@ -0,0 +1,192 @@
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, TSurveyQuestionSummaryDate } from "@formbricks/types/surveys/types";
import { DateQuestionSummary } from "./DateQuestionSummary";
vi.mock("@/lib/time", () => ({
timeSince: () => "2 hours ago",
}));
vi.mock("@/lib/utils/contact", () => ({
getContactIdentifier: () => "contact@example.com",
}));
vi.mock("@/lib/utils/datetime", () => ({
formatDateWithOrdinal: (_: Date) => "January 1st, 2023",
}));
vi.mock("@/modules/ui/components/avatars", () => ({
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => (
<button onClick={onClick} data-testid="load-more-button">
{children}
</button>
),
}));
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href} data-testid="next-link">
{children}
</a>
),
}));
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
}));
describe("DateQuestionSummary", () => {
afterEach(() => {
cleanup();
});
const environmentId = "env-123";
const survey = {} as TSurvey;
const locale = "en-US";
test("renders table headers correctly", () => {
const questionSummary = {
question: { id: "q1", headline: "Date Question" },
samples: [],
} as unknown as TSurveyQuestionSummaryDate;
render(
<DateQuestionSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
expect(screen.getByText("common.user")).toBeInTheDocument();
expect(screen.getByText("common.response")).toBeInTheDocument();
expect(screen.getByText("common.time")).toBeInTheDocument();
});
test("renders date responses correctly", () => {
const questionSummary = {
question: { id: "q1", headline: "Date Question" },
samples: [
{
id: "response1",
value: "2023-01-01",
updatedAt: new Date().toISOString(),
contact: { id: "contact1" },
contactAttributes: {},
},
],
} as unknown as TSurveyQuestionSummaryDate;
render(
<DateQuestionSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByText("January 1st, 2023")).toBeInTheDocument();
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
});
test("renders invalid dates with special message", () => {
const questionSummary = {
question: { id: "q1", headline: "Date Question" },
samples: [
{
id: "response1",
value: "invalid-date",
updatedAt: new Date().toISOString(),
contact: { id: "contact1" },
contactAttributes: {},
},
],
} as unknown as TSurveyQuestionSummaryDate;
render(
<DateQuestionSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByText("common.invalid_date(invalid-date)")).toBeInTheDocument();
});
test("renders anonymous user when no contact is provided", () => {
const questionSummary = {
question: { id: "q1", headline: "Date Question" },
samples: [
{
id: "response1",
value: "2023-01-01",
updatedAt: new Date().toISOString(),
contact: null,
contactAttributes: {},
},
],
} as unknown as TSurveyQuestionSummaryDate;
render(
<DateQuestionSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
});
test("shows load more button when there are more responses and loads more on click", async () => {
const samples = Array.from({ length: 15 }, (_, i) => ({
id: `response${i}`,
value: "2023-01-01",
updatedAt: new Date().toISOString(),
contact: null,
contactAttributes: {},
}));
const questionSummary = {
question: { id: "q1", headline: "Date Question" },
samples,
} as unknown as TSurveyQuestionSummaryDate;
render(
<DateQuestionSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
// Initially 10 responses should be visible
expect(screen.getAllByText("January 1st, 2023")).toHaveLength(10);
// "Load More" button should be visible
const loadMoreButton = screen.getByTestId("load-more-button");
expect(loadMoreButton).toBeInTheDocument();
// Click "Load More"
await userEvent.click(loadMoreButton);
// Now all 15 responses should be visible
expect(screen.getAllByText("January 1st, 2023")).toHaveLength(15);
// "Load More" button should disappear
expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument();
});
});

View File

@@ -1,71 +0,0 @@
"use client";
import { generateInsightsForSurveyAction } from "@/modules/ee/insights/actions";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { SparklesIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
interface EnableInsightsBannerProps {
surveyId: string;
maxResponseCount: number;
surveyResponseCount: number;
}
export const EnableInsightsBanner = ({
surveyId,
surveyResponseCount,
maxResponseCount,
}: EnableInsightsBannerProps) => {
const { t } = useTranslate();
const [isGeneratingInsights, setIsGeneratingInsights] = useState(false);
const handleInsightGeneration = async () => {
toast.success("Generating insights for this survey. Please check back in a few minutes.", {
duration: 3000,
});
setIsGeneratingInsights(true);
toast.success(t("environments.surveys.summary.enable_ai_insights_banner_success"));
generateInsightsForSurveyAction({ surveyId });
};
if (isGeneratingInsights) {
return null;
}
return (
<Alert className="mb-6 mt-4 flex items-center gap-4 border-slate-400 bg-white">
<div>
<SparklesIcon strokeWidth={1.5} className="size-7 text-slate-700" />
</div>
<div className="flex-1">
<AlertTitle>
<span className="mr-2">{t("environments.surveys.summary.enable_ai_insights_banner_title")}</span>
<Badge type="gray" size="normal" text="Beta" />
</AlertTitle>
<AlertDescription className="flex items-start justify-between gap-4">
{t("environments.surveys.summary.enable_ai_insights_banner_description")}
</AlertDescription>
</div>
<TooltipRenderer
tooltipContent={
surveyResponseCount > maxResponseCount
? t("environments.surveys.summary.enable_ai_insights_banner_tooltip")
: undefined
}>
<Button
size="sm"
className="shrink-0"
onClick={handleInsightGeneration}
loading={isGeneratingInsights}
disabled={surveyResponseCount > maxResponseCount}>
{t("environments.surveys.summary.enable_ai_insights_banner_button")}
</Button>
</TooltipRenderer>
</Alert>
);
};

View File

@@ -0,0 +1,231 @@
import { FileUploadSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary";
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,
TSurveyFileUploadQuestion,
TSurveyQuestionSummaryFileUpload,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
// Mock child components and hooks
vi.mock("@/modules/ui/components/avatars", () => ({
PersonAvatar: vi.fn(() => <div>PersonAvatarMock</div>),
}));
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: vi.fn(() => <div>QuestionSummaryHeaderMock</div>),
}));
// Mock utility functions
vi.mock("@/lib/storage/utils", () => ({
getOriginalFileNameFromUrl: (url: string) => `original-${url.split("/").pop()}`,
}));
vi.mock("@/lib/time", () => ({
timeSince: () => "some time ago",
}));
vi.mock("@/lib/utils/contact", () => ({
getContactIdentifier: () => "contact@example.com",
}));
const environmentId = "test-env-id";
const survey = { id: "survey-1" } as TSurvey;
const locale = "en-US";
const createMockResponse = (id: string, value: string[], contactId: string | null = null) => ({
id: `response-${id}`,
value,
updatedAt: new Date().toISOString(),
contact: contactId ? { id: contactId, name: `Contact ${contactId}` } : null,
contactAttributes: contactId ? { email: `contact${contactId}@example.com` } : {},
});
const questionSummaryBase = {
question: {
id: "q1",
headline: { default: "Upload your file" },
type: TSurveyQuestionTypeEnum.FileUpload,
} as unknown as TSurveyFileUploadQuestion,
responseCount: 0,
files: [],
} as unknown as TSurveyQuestionSummaryFileUpload;
describe("FileUploadSummary", () => {
afterEach(() => {
cleanup();
});
test("renders the component with initial responses", () => {
const files = Array.from({ length: 5 }, (_, i) =>
createMockResponse(i.toString(), [`https://example.com/file${i}.pdf`], `contact-${i}`)
);
const questionSummary = {
...questionSummaryBase,
files,
responseCount: files.length,
} as unknown as TSurveyQuestionSummaryFileUpload;
render(
<FileUploadSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByText("QuestionSummaryHeaderMock")).toBeInTheDocument();
expect(screen.getByText("common.user")).toBeInTheDocument();
expect(screen.getByText("common.response")).toBeInTheDocument();
expect(screen.getByText("common.time")).toBeInTheDocument();
expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(5);
expect(screen.getAllByText("contact@example.com")).toHaveLength(5);
expect(screen.getByText("original-file0.pdf")).toBeInTheDocument();
expect(screen.getByText("original-file4.pdf")).toBeInTheDocument();
expect(screen.queryByText("common.load_more")).not.toBeInTheDocument();
});
test("renders 'Skipped' when value is an empty array", () => {
const files = [createMockResponse("skipped", [], "contact-skipped")];
const questionSummary = {
...questionSummaryBase,
files,
responseCount: files.length,
} as unknown as TSurveyQuestionSummaryFileUpload;
render(
<FileUploadSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByText("common.skipped")).toBeInTheDocument();
expect(screen.queryByText(/original-/)).not.toBeInTheDocument(); // No file name should be rendered
});
test("renders 'Anonymous' when contact is null", () => {
const files = [createMockResponse("anon", ["https://example.com/anonfile.jpg"], null)];
const questionSummary = {
...questionSummaryBase,
files,
responseCount: files.length,
} as unknown as TSurveyQuestionSummaryFileUpload;
render(
<FileUploadSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
expect(screen.getByText("original-anonfile.jpg")).toBeInTheDocument();
});
test("shows 'Load More' button when there are more than 10 responses and loads more on click", async () => {
const files = Array.from({ length: 15 }, (_, i) =>
createMockResponse(i.toString(), [`https://example.com/file${i}.txt`], `contact-${i}`)
);
const questionSummary = {
...questionSummaryBase,
files,
responseCount: files.length,
} as unknown as TSurveyQuestionSummaryFileUpload;
render(
<FileUploadSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
// Initially 10 responses should be visible
expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(10);
expect(screen.getByText("original-file9.txt")).toBeInTheDocument();
expect(screen.queryByText("original-file10.txt")).not.toBeInTheDocument();
// "Load More" button should be visible
const loadMoreButton = screen.getByText("common.load_more");
expect(loadMoreButton).toBeInTheDocument();
// Click "Load More"
await userEvent.click(loadMoreButton);
// Now all 15 responses should be visible
expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(15);
expect(screen.getByText("original-file14.txt")).toBeInTheDocument();
// "Load More" button should disappear
expect(screen.queryByText("common.load_more")).not.toBeInTheDocument();
});
test("renders multiple files for a single response", () => {
const files = [
createMockResponse(
"multi",
["https://example.com/fileA.png", "https://example.com/fileB.docx"],
"contact-multi"
),
];
const questionSummary = {
...questionSummaryBase,
files,
responseCount: files.length,
} as unknown as TSurveyQuestionSummaryFileUpload;
render(
<FileUploadSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByText("original-fileA.png")).toBeInTheDocument();
expect(screen.getByText("original-fileB.docx")).toBeInTheDocument();
// Check that download links exist
const links = screen.getAllByRole("link");
// 1 contact link + 2 file links
expect(links.filter((link) => link.getAttribute("target") === "_blank")).toHaveLength(2);
expect(
links.find((link) => link.getAttribute("href") === "https://example.com/fileA.png")
).toBeInTheDocument();
expect(
links.find((link) => link.getAttribute("href") === "https://example.com/fileB.docx")
).toBeInTheDocument();
});
test("renders contact link correctly", () => {
const contactId = "contact-link-test";
const files = [createMockResponse("link", ["https://example.com/link.pdf"], contactId)];
const questionSummary = {
...questionSummaryBase,
files,
responseCount: files.length,
} as unknown as TSurveyQuestionSummaryFileUpload;
render(
<FileUploadSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
const contactLink = screen.getByText("contact@example.com").closest("a");
expect(contactLink).toBeInTheDocument();
expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/${contactId}`);
});
});

View File

@@ -74,12 +74,12 @@ export const FileUploadSummary = ({
<div className="col-span-2 grid">
{Array.isArray(response.value) &&
(response.value.length > 0 ? (
response.value.map((fileUrl, index) => {
response.value.map((fileUrl) => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl} key={index} target="_blank" rel="noopener noreferrer">
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
<div className="absolute top-0 right-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />

View File

@@ -0,0 +1,183 @@
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 { TSurveyQuestionSummaryHiddenFields } from "@formbricks/types/surveys/types";
import { HiddenFieldsSummary } from "./HiddenFieldsSummary";
// Mock dependencies
vi.mock("@/lib/time", () => ({
timeSince: () => "2 hours ago",
}));
vi.mock("@/lib/utils/contact", () => ({
getContactIdentifier: () => "contact@example.com",
}));
vi.mock("@/modules/ui/components/avatars", () => ({
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => (
<button onClick={onClick} data-testid="load-more-button">
{children}
</button>
),
}));
// Mock lucide-react components
vi.mock("lucide-react", () => ({
InboxIcon: () => <div data-testid="inbox-icon" />,
MessageSquareTextIcon: () => <div data-testid="message-icon" />,
Link: ({ children, href, className }: { children: React.ReactNode; href: string; className: string }) => (
<a href={href} className={className} data-testid="lucide-link">
{children}
</a>
),
}));
// Mock Next.js Link
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href} data-testid="next-link">
{children}
</a>
),
}));
describe("HiddenFieldsSummary", () => {
afterEach(() => {
cleanup();
});
const environment = { id: "env-123" } as TEnvironment;
const locale = "en-US";
test("renders component with correct header and single response", () => {
const questionSummary = {
id: "hidden-field-1",
responseCount: 1,
samples: [
{
id: "response1",
value: "Hidden value",
updatedAt: new Date().toISOString(),
contact: { id: "contact1" },
contactAttributes: {},
},
],
} as unknown as TSurveyQuestionSummaryHiddenFields;
render(
<HiddenFieldsSummary environment={environment} questionSummary={questionSummary} locale={locale} />
);
expect(screen.getByText("hidden-field-1")).toBeInTheDocument();
expect(screen.getByText("Hidden Field")).toBeInTheDocument();
expect(screen.getByText("1 common.response")).toBeInTheDocument();
// Headers
expect(screen.getByText("common.user")).toBeInTheDocument();
expect(screen.getByText("common.response")).toBeInTheDocument();
expect(screen.getByText("common.time")).toBeInTheDocument();
// We can skip checking for PersonAvatar as it's inside hidden md:flex
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
expect(screen.getByText("Hidden value")).toBeInTheDocument();
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
// Check for link without checking for specific href
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
});
test("renders anonymous user when no contact is provided", () => {
const questionSummary = {
id: "hidden-field-1",
responseCount: 1,
samples: [
{
id: "response1",
value: "Anonymous hidden value",
updatedAt: new Date().toISOString(),
contact: null,
contactAttributes: {},
},
],
} as unknown as TSurveyQuestionSummaryHiddenFields;
render(
<HiddenFieldsSummary environment={environment} questionSummary={questionSummary} locale={locale} />
);
// Instead of checking for avatar, just check for anonymous text
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
expect(screen.getByText("Anonymous hidden value")).toBeInTheDocument();
});
test("renders plural response label when multiple responses", () => {
const questionSummary = {
id: "hidden-field-1",
responseCount: 2,
samples: [
{
id: "response1",
value: "Hidden value 1",
updatedAt: new Date().toISOString(),
contact: { id: "contact1" },
contactAttributes: {},
},
{
id: "response2",
value: "Hidden value 2",
updatedAt: new Date().toISOString(),
contact: { id: "contact2" },
contactAttributes: {},
},
],
} as unknown as TSurveyQuestionSummaryHiddenFields;
render(
<HiddenFieldsSummary environment={environment} questionSummary={questionSummary} locale={locale} />
);
expect(screen.getByText("2 common.responses")).toBeInTheDocument();
expect(screen.getAllByText("contact@example.com")).toHaveLength(2);
});
test("shows load more button when there are more responses and loads more on click", async () => {
const samples = Array.from({ length: 15 }, (_, i) => ({
id: `response${i}`,
value: `Hidden value ${i}`,
updatedAt: new Date().toISOString(),
contact: null,
contactAttributes: {},
}));
const questionSummary = {
id: "hidden-field-1",
responseCount: samples.length,
samples,
} as unknown as TSurveyQuestionSummaryHiddenFields;
render(
<HiddenFieldsSummary environment={environment} questionSummary={questionSummary} locale={locale} />
);
// Initially 10 responses should be visible
expect(screen.getAllByText(/Hidden value \d+/)).toHaveLength(10);
// "Load More" button should be visible
const loadMoreButton = screen.getByTestId("load-more-button");
expect(loadMoreButton).toBeInTheDocument();
// Click "Load More"
await userEvent.click(loadMoreButton);
// Now all 15 responses should be visible
expect(screen.getAllByText(/Hidden value \d+/)).toHaveLength(15);
// "Load More" button should disappear
expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,47 @@
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 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
<th className="p-4 pt-0 pb-3 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 text-ellipsis whitespace-nowrap p-4">
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap">
<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
)}>
<div
<button
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}
</div>
</button>
</TooltipRenderer>
</td>
))}

View File

@@ -0,0 +1,275 @@
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 { useState } from "react";
import { Fragment, useState } from "react";
import {
TI18nString,
TSurvey,
@@ -45,10 +45,15 @@ 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) => {
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'
const aHasOthers = (a.others?.length ?? 0) > 0;
const bHasOthers = (b.others?.length ?? 0) > 0;
return b.count - a.count; // Sort by count
// 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;
});
const handleLoadMore = (e: React.MouseEvent) => {
@@ -80,40 +85,41 @@ 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) => (
<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)}%
<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")}
</p>
</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")}
</p>
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
</button>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200" onClick={(e) => e.stopPropagation()}>
<div className="mt-4 rounded-lg border border-slate-200">
<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")}
@@ -124,11 +130,9 @@ export const MultipleChoiceSummary = ({
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={idx} dir="auto">
<div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<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">
<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">
<span>{otherValue.value}</span>
</div>
)}
@@ -139,7 +143,6 @@ 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>
@@ -163,7 +166,7 @@ export const MultipleChoiceSummary = ({
)}
</div>
)}
</div>
</Fragment>
))}
</div>
</div>

View File

@@ -0,0 +1,60 @@
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,14 +62,17 @@ 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 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<div className="cursor-pointer hover:opacity-80" key={group} onClick={() => applyFilter(group)}>
<button
className="w-full 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 capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
@@ -87,11 +90,11 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</div>
</button>
))}
</div>
<div className="flex justify-center pb-4 pt-4">
<div className="flex justify-center pt-4 pb-4">
<HalfCircle value={questionSummary.score} />
</div>
</div>

View File

@@ -0,0 +1,174 @@
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, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
import { OpenTextSummary } from "./OpenTextSummary";
// Mock dependencies
vi.mock("@/lib/time", () => ({
timeSince: () => "2 hours ago",
}));
vi.mock("@/lib/utils/contact", () => ({
getContactIdentifier: () => "contact@example.com",
}));
vi.mock("@/modules/analysis/utils", () => ({
renderHyperlinkedContent: (text: string) => <div data-testid="hyperlinked-content">{text}</div>,
}));
vi.mock("@/modules/ui/components/avatars", () => ({
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => (
<button onClick={onClick} data-testid="load-more-button">
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
SecondaryNavigation: ({ activeId, navigation }: any) => (
<div data-testid="secondary-navigation">
{navigation.map((item: any) => (
<button key={item.id} onClick={item.onClick} data-active={activeId === item.id}>
{item.label}
</button>
))}
</div>
),
}));
vi.mock("@/modules/ui/components/table", () => ({
Table: ({ children }: { children: React.ReactNode }) => <table data-testid="table">{children}</table>,
TableHeader: ({ children }: { children: React.ReactNode }) => <thead>{children}</thead>,
TableBody: ({ children }: { children: React.ReactNode }) => <tbody>{children}</tbody>,
TableRow: ({ children }: { children: React.ReactNode }) => <tr>{children}</tr>,
TableHead: ({ children }: { children: React.ReactNode }) => <th>{children}</th>,
TableCell: ({ children, width }: { children: React.ReactNode; width?: number }) => (
<td style={width ? { width } : {}}>{children}</td>
),
}));
vi.mock("@/modules/ee/insights/components/insights-view", () => ({
InsightView: () => <div data-testid="insight-view"></div>,
}));
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: ({ additionalInfo }: { additionalInfo?: React.ReactNode }) => (
<div data-testid="question-summary-header">{additionalInfo}</div>
),
}));
describe("OpenTextSummary", () => {
afterEach(() => {
cleanup();
});
const environmentId = "env-123";
const survey = { id: "survey-1" } as TSurvey;
const locale = "en-US";
test("renders response mode by default when insights not enabled", () => {
const questionSummary = {
question: { id: "q1", headline: "Open Text Question" },
samples: [
{
id: "response1",
value: "Sample response text",
updatedAt: new Date().toISOString(),
contact: { id: "contact1" },
contactAttributes: {},
},
],
} as unknown as TSurveyQuestionSummaryOpenText;
render(
<OpenTextSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
expect(screen.getByTestId("table")).toBeInTheDocument();
expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1");
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
expect(screen.getByTestId("hyperlinked-content")).toHaveTextContent("Sample response text");
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
// No secondary navigation when insights not enabled
expect(screen.queryByTestId("secondary-navigation")).not.toBeInTheDocument();
});
test("renders anonymous user when no contact is provided", () => {
const questionSummary = {
question: { id: "q1", headline: "Open Text Question" },
samples: [
{
id: "response1",
value: "Anonymous response",
updatedAt: new Date().toISOString(),
contact: null,
contactAttributes: {},
},
],
} as unknown as TSurveyQuestionSummaryOpenText;
render(
<OpenTextSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous");
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
});
test("shows load more button when there are more responses and loads more on click", async () => {
const samples = Array.from({ length: 15 }, (_, i) => ({
id: `response${i}`,
value: `Response ${i}`,
updatedAt: new Date().toISOString(),
contact: null,
contactAttributes: {},
}));
const questionSummary = {
question: { id: "q1", headline: "Open Text Question" },
samples,
} as unknown as TSurveyQuestionSummaryOpenText;
render(
<OpenTextSummary
questionSummary={questionSummary}
environmentId={environmentId}
survey={survey}
locale={locale}
/>
);
// Initially 10 responses should be visible
expect(screen.getAllByTestId("hyperlinked-content")).toHaveLength(10);
// "Load More" button should be visible
const loadMoreButton = screen.getByTestId("load-more-button");
expect(loadMoreButton).toBeInTheDocument();
// Click "Load More"
await userEvent.click(loadMoreButton);
// Now all 15 responses should be visible
expect(screen.getAllByTestId("hyperlinked-content")).toHaveLength(15);
// "Load More" button should disappear
expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument();
});
});

View File

@@ -3,10 +3,8 @@
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { InsightView } from "@/modules/ee/insights/components/insights-view";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
@@ -19,25 +17,12 @@ interface OpenTextSummaryProps {
questionSummary: TSurveyQuestionSummaryOpenText;
environmentId: string;
survey: TSurvey;
isAIEnabled: boolean;
documentsPerPage?: number;
locale: TUserLocale;
}
export const OpenTextSummary = ({
questionSummary,
environmentId,
survey,
isAIEnabled,
documentsPerPage,
locale,
}: OpenTextSummaryProps) => {
export const OpenTextSummary = ({ questionSummary, environmentId, survey, locale }: OpenTextSummaryProps) => {
const { t } = useTranslate();
const isInsightsEnabled = isAIEnabled && questionSummary.insightsEnabled;
const [visibleResponses, setVisibleResponses] = useState(10);
const [activeTab, setActiveTab] = useState<"insights" | "responses">(
isInsightsEnabled && questionSummary.insights.length ? "insights" : "responses"
);
const handleLoadMore = () => {
// Increase the number of visible responses by 10, not exceeding the total number of responses
@@ -46,104 +31,62 @@ export const OpenTextSummary = ({
);
};
const tabNavigation = [
{
id: "insights",
label: t("common.insights"),
onClick: () => setActiveTab("insights"),
},
{
id: "responses",
label: t("common.responses"),
onClick: () => setActiveTab("responses"),
},
];
return (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
additionalInfo={
isAIEnabled && questionSummary.insightsEnabled === false ? (
<div className="flex items-center space-x-2">
<div className="flex items-center rounded-lg bg-slate-100 p-2">
{t("environments.surveys.summary.insights_disabled")}
</div>
</div>
) : undefined
}
/>
{isInsightsEnabled && (
<div className="ml-4">
<SecondaryNavigation activeId={activeTab} navigation={tabNavigation} />
</div>
)}
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="border-t border-slate-200"></div>
<div className="max-h-[40vh] overflow-y-auto">
{activeTab === "insights" ? (
<InsightView
insights={questionSummary.insights}
questionId={questionSummary.question.id}
surveyId={survey.id}
documentsPerPage={documentsPerPage}
locale={locale}
/>
) : activeTab === "responses" ? (
<>
<Table>
<TableHeader className="bg-slate-100">
<TableRow>
<TableHead>{t("common.user")}</TableHead>
<TableHead>{t("common.response")}</TableHead>
<TableHead>{t("common.time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow key={response.id}>
<TableCell>
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</TableCell>
<TableCell className="font-medium">
{typeof response.value === "string"
? renderHyperlinkedContent(response.value)
: response.value}
</TableCell>
<TableCell width={120}>
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</>
) : null}
<Table>
<TableHeader className="bg-slate-100">
<TableRow>
<TableHead>{t("common.user")}</TableHead>
<TableHead>{t("common.response")}</TableHead>
<TableHead>{t("common.time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow key={response.id}>
<TableCell>
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</TableCell>
<TableCell className="font-medium">
{typeof response.value === "string"
? renderHyperlinkedContent(response.value)
: response.value}
</TableCell>
<TableCell width={120}>
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,91 @@
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 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{results.map((result, index) => (
<div
className="cursor-pointer hover:opacity-80"
<button
className="w-full 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} />
</div>
</button>
))}
</div>
</div>

View File

@@ -0,0 +1,164 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionSummary, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
// Mock dependencies
vi.mock("@/lib/utils/recall", () => ({
recallToHeadline: () => ({ default: "Recalled Headline" }),
}));
vi.mock("@/modules/survey/editor/lib/utils", () => ({
formatTextWithSlashes: (text: string) => <span data-testid="formatted-headline">{text}</span>,
}));
vi.mock("@/modules/survey/lib/questions", () => ({
getQuestionTypes: () => [
{
id: "openText",
label: "Open Text",
icon: () => <div data-testid="question-icon">Icon</div>,
},
{
id: "multipleChoice",
label: "Multiple Choice",
icon: () => <div data-testid="question-icon">Icon</div>,
},
],
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: ({ title, id }: { title: string; id: string }) => (
<div data-testid="settings-id">
{title}: {id}
</div>
),
}));
// Mock InboxIcon
vi.mock("lucide-react", () => ({
InboxIcon: () => <div data-testid="inbox-icon"></div>,
}));
describe("QuestionSummaryHeader", () => {
afterEach(() => {
cleanup();
});
const survey = {} as TSurvey;
test("renders header with question headline and type", () => {
const questionSummary = {
question: {
id: "q1",
headline: { default: "Test Question" },
type: "openText" as TSurveyQuestionTypeEnum,
required: true,
},
responseCount: 42,
} as unknown as TSurveyQuestionSummary;
render(<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />);
expect(screen.getByTestId("formatted-headline")).toHaveTextContent("Recalled Headline");
// Look for text content with a more specific approach
const questionTypeElement = screen.getByText((content) => {
return content.includes("Open Text") && !content.includes("common.question_id");
});
expect(questionTypeElement).toBeInTheDocument();
// Check for responses text specifically
expect(
screen.getByText((content) => {
return content.includes("42") && content.includes("common.responses");
})
).toBeInTheDocument();
expect(screen.getByTestId("question-icon")).toBeInTheDocument();
expect(screen.getByTestId("settings-id")).toHaveTextContent("common.question_id: q1");
expect(screen.queryByText("environments.surveys.edit.optional")).not.toBeInTheDocument();
});
test("shows 'optional' tag when question is not required", () => {
const questionSummary = {
question: {
id: "q2",
headline: { default: "Optional Question" },
type: "multipleChoice" as TSurveyQuestionTypeEnum,
required: false,
},
responseCount: 10,
} as unknown as TSurveyQuestionSummary;
render(<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />);
expect(screen.getByText("environments.surveys.edit.optional")).toBeInTheDocument();
});
test("hides response count when showResponses is false", () => {
const questionSummary = {
question: {
id: "q3",
headline: { default: "No Response Count Question" },
type: "openText" as TSurveyQuestionTypeEnum,
required: true,
},
responseCount: 15,
} as unknown as TSurveyQuestionSummary;
render(<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} showResponses={false} />);
expect(
screen.queryByText((content) => content.includes("15") && content.includes("common.responses"))
).not.toBeInTheDocument();
});
test("shows unknown question type for unrecognized type", () => {
const questionSummary = {
question: {
id: "q4",
headline: { default: "Unknown Type Question" },
type: "unknownType" as TSurveyQuestionTypeEnum,
required: true,
},
responseCount: 5,
} as unknown as TSurveyQuestionSummary;
render(<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />);
// Look for text in the question type element specifically
const unknownTypeElement = screen.getByText((content) => {
return (
content.includes("environments.surveys.summary.unknown_question_type") &&
!content.includes("common.question_id")
);
});
expect(unknownTypeElement).toBeInTheDocument();
});
test("renders additional info when provided", () => {
const questionSummary = {
question: {
id: "q5",
headline: { default: "With Additional Info" },
type: "openText" as TSurveyQuestionTypeEnum,
required: true,
},
responseCount: 20,
} as unknown as TSurveyQuestionSummary;
const additionalInfo = <div data-testid="additional-info">Extra Information</div>;
render(
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
additionalInfo={additionalInfo}
/>
);
expect(screen.getByTestId("additional-info")).toBeInTheDocument();
expect(screen.getByText("Extra Information")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,104 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types";
import { RankingSummary } from "./RankingSummary";
// Mock dependencies
vi.mock("./QuestionSummaryHeader", () => ({
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
}));
vi.mock("../lib/utils", () => ({
convertFloatToNDecimal: (value: number) => value.toFixed(2),
}));
describe("RankingSummary", () => {
afterEach(() => {
cleanup();
});
const survey = {} as TSurvey;
const surveyType: TSurveyType = "app";
test("renders ranking results in correct order", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
choices: {
option1: { value: "Option A", avgRanking: 1.5, others: [] },
option2: { value: "Option B", avgRanking: 2.3, others: [] },
option3: { value: "Option C", avgRanking: 1.2, others: [] },
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
// Check order: should be sorted by avgRanking (ascending)
const options = screen.getAllByText(/Option [A-C]/);
expect(options[0]).toHaveTextContent("Option C"); // 1.2 (lowest avgRanking first)
expect(options[1]).toHaveTextContent("Option A"); // 1.5
expect(options[2]).toHaveTextContent("Option B"); // 2.3
// Check rankings are displayed
expect(screen.getByText("#1")).toBeInTheDocument();
expect(screen.getByText("#2")).toBeInTheDocument();
expect(screen.getByText("#3")).toBeInTheDocument();
// Check average values are displayed
expect(screen.getByText("#1.20")).toBeInTheDocument();
expect(screen.getByText("#1.50")).toBeInTheDocument();
expect(screen.getByText("#2.30")).toBeInTheDocument();
});
test("renders 'other values found' section when others exist", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
choices: {
option1: {
value: "Option A",
avgRanking: 1.0,
others: [{ value: "Other value", count: 2 }],
},
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeInTheDocument();
});
test("shows 'User' column in other values section for app survey type", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
choices: {
option1: {
value: "Option A",
avgRanking: 1.0,
others: [{ value: "Other value", count: 1 }],
},
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="app" />);
expect(screen.getByText("common.user")).toBeInTheDocument();
});
test("doesn't show 'User' column for link survey type", () => {
const questionSummary = {
question: { id: "q1", headline: "Rank the following" },
choices: {
option1: {
value: "Option A",
avgRanking: 1.0,
others: [{ value: "Other value", count: 1 }],
},
},
} as unknown as TSurveyQuestionSummaryRanking;
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="link" />);
expect(screen.queryByText("common.user")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,87 @@
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 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => (
<div
className="cursor-pointer hover:opacity-80"
<button
className="w-full 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} />
</div>
</button>
))}
</div>
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (

View File

@@ -0,0 +1,125 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionTypeEnum, TSurveySummary } from "@formbricks/types/surveys/types";
import { SummaryDropOffs } from "./SummaryDropOffs";
// Mock dependencies
vi.mock("@/lib/utils/recall", () => ({
recallToHeadline: () => ({ default: "Recalled Question" }),
}));
vi.mock("@/modules/survey/editor/lib/utils", () => ({
formatTextWithSlashes: (text) => <span data-testid="formatted-text">{text}</span>,
}));
vi.mock("@/modules/survey/lib/questions", () => ({
getQuestionIcon: () => () => <div data-testid="question-icon" />,
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-trigger">{children}</div>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-content">{children}</div>
),
}));
vi.mock("lucide-react", () => ({
TimerIcon: () => <div data-testid="timer-icon" />,
}));
describe("SummaryDropOffs", () => {
afterEach(() => {
cleanup();
});
const mockSurvey = {} as TSurvey;
const mockDropOff: TSurveySummary["dropOff"] = [
{
questionId: "q1",
headline: "First Question",
questionType: TSurveyQuestionTypeEnum.OpenText,
ttc: 15000, // 15 seconds
impressions: 100,
dropOffCount: 20,
dropOffPercentage: 20,
},
{
questionId: "q2",
headline: "Second Question",
questionType: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
ttc: 30000, // 30 seconds
impressions: 80,
dropOffCount: 15,
dropOffPercentage: 18.75,
},
{
questionId: "q3",
headline: "Third Question",
questionType: TSurveyQuestionTypeEnum.Rating,
ttc: 0, // No time data
impressions: 65,
dropOffCount: 10,
dropOffPercentage: 15.38,
},
];
test("renders header row with correct columns", () => {
render(<SummaryDropOffs dropOff={mockDropOff} survey={mockSurvey} />);
// Check header
expect(screen.getByText("common.questions")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument();
expect(screen.getByTestId("timer-icon")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.impressions")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.drop_offs")).toBeInTheDocument();
});
test("renders tooltip with correct content", () => {
render(<SummaryDropOffs dropOff={mockDropOff} survey={mockSurvey} />);
expect(screen.getByTestId("tooltip-content")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.ttc_tooltip")).toBeInTheDocument();
});
test("renders all drop-off items with correct data", () => {
render(<SummaryDropOffs dropOff={mockDropOff} survey={mockSurvey} />);
// There should be 3 rows of data (one for each question)
expect(screen.getAllByTestId("question-icon")).toHaveLength(3);
expect(screen.getAllByTestId("formatted-text")).toHaveLength(3);
// Check time to complete values
expect(screen.getByText("15.00s")).toBeInTheDocument(); // 15000ms converted to seconds
expect(screen.getByText("30.00s")).toBeInTheDocument(); // 30000ms converted to seconds
expect(screen.getByText("N/A")).toBeInTheDocument(); // 0ms shown as N/A
// Check impressions values
expect(screen.getByText("100")).toBeInTheDocument();
expect(screen.getByText("80")).toBeInTheDocument();
expect(screen.getByText("65")).toBeInTheDocument();
// Check drop-off counts and percentages
expect(screen.getByText("20")).toBeInTheDocument();
expect(screen.getByText("(20%)")).toBeInTheDocument();
expect(screen.getByText("15")).toBeInTheDocument();
expect(screen.getByText("(19%)")).toBeInTheDocument(); // 18.75% rounded to 19%
expect(screen.getByText("10")).toBeInTheDocument();
expect(screen.getByText("(15%)")).toBeInTheDocument(); // 15.38% rounded to 15%
});
test("renders empty state when dropOff array is empty", () => {
render(<SummaryDropOffs dropOff={[]} survey={mockSurvey} />);
// Header should still be visible
expect(screen.getByText("common.questions")).toBeInTheDocument();
// But no question icons
expect(screen.queryByTestId("question-icon")).not.toBeInTheDocument();
});
});

View File

@@ -39,8 +39,6 @@ interface SummaryListProps {
environment: TEnvironment;
survey: TSurvey;
totalResponseCount: number;
isAIEnabled: boolean;
documentsPerPage?: number;
locale: TUserLocale;
}
@@ -50,8 +48,6 @@ export const SummaryList = ({
responseCount,
survey,
totalResponseCount,
isAIEnabled,
documentsPerPage,
locale,
}: SummaryListProps) => {
const { setSelectedFilter, selectedFilter } = useResponseFilter();
@@ -134,8 +130,6 @@ export const SummaryList = ({
questionSummary={questionSummary}
environmentId={environment.id}
survey={survey}
isAIEnabled={isAIEnabled}
documentsPerPage={documentsPerPage}
locale={locale}
/>
);

View File

@@ -0,0 +1,135 @@
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,6 +71,8 @@ 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">
@@ -99,9 +101,7 @@ export const SummaryMetadata = ({
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<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">
<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">
<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>
) : (
dropOffCount
displayCountValue
)}
</span>
{!isLoading && (
<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">
<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)}>
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</span>
</button>
)}
</div>
</div>
@@ -135,6 +135,7 @@ export const SummaryMetadata = ({
</TooltipContent>
</Tooltip>
</TooltipProvider>
<StatCard
label={t("environments.surveys.summary.time_to_complete")}
percentage={null}

View File

@@ -0,0 +1,228 @@
import { cleanup, render, screen, waitFor } 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 { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { SummaryPage } from "./SummaryPage";
// Mock actions
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions", () => ({
getResponseCountAction: vi.fn().mockResolvedValue({ data: 42 }),
getSurveySummaryAction: vi.fn().mockResolvedValue({
data: {
meta: {
completedPercentage: 80,
completedResponses: 40,
displayCount: 50,
dropOffPercentage: 20,
dropOffCount: 10,
startsPercentage: 100,
totalResponses: 50,
ttcAverage: 120,
},
dropOff: [
{
questionId: "q1",
headline: "Question 1",
questionType: "openText",
ttc: 20000,
impressions: 50,
dropOffCount: 5,
dropOffPercentage: 10,
},
],
summary: [
{
question: { id: "q1", headline: "Question 1", type: "openText", required: true },
responseCount: 45,
type: "openText",
samples: [],
},
],
},
}),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getResponseCountBySurveySharingKeyAction: vi.fn().mockResolvedValue({ data: 42 }),
getSummaryBySurveySharingKeyAction: vi.fn().mockResolvedValue({
data: {
meta: {
completedPercentage: 80,
completedResponses: 40,
displayCount: 50,
dropOffPercentage: 20,
dropOffCount: 10,
startsPercentage: 100,
totalResponses: 50,
ttcAverage: 120,
},
dropOff: [
{
questionId: "q1",
headline: "Question 1",
questionType: "openText",
ttc: 20000,
impressions: 50,
dropOffCount: 5,
dropOffPercentage: 10,
},
],
summary: [
{
question: { id: "q1", headline: "Question 1", type: "openText", required: true },
responseCount: 45,
type: "openText",
samples: [],
},
],
},
}),
}));
// Mock components
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs",
() => ({
SummaryDropOffs: () => <div data-testid="summary-drop-offs">DropOffs Component</div>,
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList",
() => ({
SummaryList: ({ summary, responseCount }: any) => (
<div data-testid="summary-list">
<span>Response Count: {responseCount}</span>
<span>Summary Items: {summary.length}</span>
</div>
),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata",
() => ({
SummaryMetadata: ({ showDropOffs, setShowDropOffs, isLoading }: any) => (
<div data-testid="summary-metadata">
<span>Is Loading: {isLoading ? "true" : "false"}</span>
<button onClick={() => setShowDropOffs(!showDropOffs)}>Toggle Dropoffs</button>
</div>
),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop",
() => ({
__esModule: true,
default: () => <div data-testid="scroll-to-top">Scroll To Top</div>,
})
);
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter", () => ({
CustomFilter: () => <div data-testid="custom-filter">Custom Filter</div>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({
ResultsShareButton: () => <div data-testid="results-share-button">Share Results</div>,
}));
// Mock context
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
useResponseFilter: () => ({
selectedFilter: { filter: [], onlyComplete: false },
dateRange: { from: null, to: null },
resetState: vi.fn(),
}),
}));
// Mock hooks
vi.mock("@/lib/utils/hooks/useIntervalWhenFocused", () => ({
useIntervalWhenFocused: vi.fn(),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey: any) => survey,
}));
vi.mock("next/navigation", () => ({
useParams: () => ({}),
useSearchParams: () => ({ get: () => null }),
}));
describe("SummaryPage", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mockEnvironment = { id: "env-123" } as TEnvironment;
const mockSurvey = {
id: "survey-123",
environmentId: "env-123",
} as TSurvey;
const locale = "en-US" as TUserLocale;
const defaultProps = {
environment: mockEnvironment,
survey: mockSurvey,
surveyId: "survey-123",
webAppUrl: "https://app.example.com",
totalResponseCount: 50,
locale,
isReadOnly: false,
};
test("renders loading state initially", () => {
render(<SummaryPage {...defaultProps} />);
expect(screen.getByTestId("summary-metadata")).toBeInTheDocument();
expect(screen.getByText("Is Loading: true")).toBeInTheDocument();
});
test("renders summary components after loading", async () => {
render(<SummaryPage {...defaultProps} />);
// Wait for loading to complete
await waitFor(() => {
expect(screen.getByText("Is Loading: false")).toBeInTheDocument();
});
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
expect(screen.getByTestId("scroll-to-top")).toBeInTheDocument();
expect(screen.getByTestId("summary-list")).toBeInTheDocument();
});
test("shows drop-offs component when toggled", async () => {
const user = userEvent.setup();
render(<SummaryPage {...defaultProps} />);
// Wait for loading to complete
await waitFor(() => {
expect(screen.getByText("Is Loading: false")).toBeInTheDocument();
});
// Drop-offs should initially be hidden
expect(screen.queryByTestId("summary-drop-offs")).not.toBeInTheDocument();
// Toggle drop-offs
await user.click(screen.getByText("Toggle Dropoffs"));
// Drop-offs should now be visible
expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument();
});
test("doesn't show share button in read-only mode", async () => {
render(<SummaryPage {...defaultProps} isReadOnly={true} />);
// Wait for loading to complete
await waitFor(() => {
expect(screen.getByText("Is Loading: false")).toBeInTheDocument();
});
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
});
});

View File

@@ -46,7 +46,6 @@ interface SummaryPageProps {
webAppUrl: string;
user?: TUser;
totalResponseCount: number;
isAIEnabled: boolean;
documentsPerPage?: number;
locale: TUserLocale;
isReadOnly: boolean;
@@ -58,8 +57,6 @@ export const SummaryPage = ({
surveyId,
webAppUrl,
totalResponseCount,
isAIEnabled,
documentsPerPage,
locale,
isReadOnly,
}: SummaryPageProps) => {
@@ -184,8 +181,6 @@ export const SummaryPage = ({
survey={surveyMemoized}
environment={environment}
totalResponseCount={totalResponseCount}
isAIEnabled={isAIEnabled}
documentsPerPage={documentsPerPage}
locale={locale}
/>
</>

View File

@@ -3,8 +3,11 @@
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { Badge } from "@/modules/ui/components/badge";
import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react";
@@ -22,6 +25,7 @@ interface SurveyAnalysisCTAProps {
isReadOnly: boolean;
user: TUser;
surveyDomain: string;
responseCount: number;
}
interface ModalState {
@@ -37,11 +41,13 @@ export const SurveyAnalysisCTA = ({
isReadOnly,
user,
surveyDomain,
responseCount,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const [loading, setLoading] = useState(false);
const [modalState, setModalState] = useState<ModalState>({
share: searchParams.get("share") === "true",
@@ -89,6 +95,24 @@ export const SurveyAnalysisCTA = ({
setModalState((prev) => ({ ...prev, dropdown: false }));
};
const duplicateSurveyAndRoute = async (surveyId: string) => {
setLoading(true);
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
environmentId: environment.id,
surveyId: surveyId,
targetEnvironmentId: environment.id,
});
if (duplicatedSurveyResponse?.data) {
toast.success(t("environments.surveys.survey_duplicated_successfully"));
router.push(`/environments/${environment.id}/surveys/${duplicatedSurveyResponse.data.id}/edit`);
} else {
const errorMessage = getFormattedErrorMessage(duplicatedSurveyResponse);
toast.error(errorMessage);
}
setIsCautionDialogOpen(false);
setLoading(false);
};
const getPreviewUrl = () => {
const separator = surveyUrl.includes("?") ? "&" : "?";
return `${surveyUrl}${separator}preview=true`;
@@ -107,6 +131,8 @@ export const SurveyAnalysisCTA = ({
{ key: "panel", modalView: "panel" as const, setOpen: handleModalState("panel") },
];
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const iconActions = [
{
icon: Eye,
@@ -144,7 +170,11 @@ export const SurveyAnalysisCTA = ({
{
icon: SquarePenIcon,
tooltip: t("common.edit"),
onClick: () => router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`),
onClick: () => {
responseCount && responseCount > 0
? setIsCautionDialogOpen(true)
: router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`);
},
isVisible: !isReadOnly,
},
];
@@ -182,6 +212,20 @@ export const SurveyAnalysisCTA = ({
<SuccessMessage environment={environment} survey={survey} />
</>
)}
{responseCount > 0 && (
<EditPublicSurveyAlertDialog
open={isCautionDialogOpen}
setOpen={setIsCautionDialogOpen}
isLoading={loading}
primaryButtonAction={() => duplicateSurveyAndRoute(survey.id)}
primaryButtonText={t("environments.surveys.edit.caution_edit_duplicate")}
secondaryButtonAction={() =>
router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`)
}
secondaryButtonText={t("common.edit")}
/>
)}
</div>
);
};

View File

@@ -1,7 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
@@ -25,12 +25,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
WEBAPP_URL: "mock-webapp-url",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
IS_PRODUCTION: true,
FB_LOGO_URL: "https://example.com/mock-logo.png",
SMTP_HOST: "mock-smtp-host",
@@ -49,10 +43,12 @@ vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
}));
const mockSearchParams = new URLSearchParams();
const mockPush = vi.fn();
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => mockSearchParams, // Reuse the same object
useRouter: () => ({ push: mockPush }),
useSearchParams: () => mockSearchParams,
usePathname: () => "/current",
}));
@@ -61,13 +57,27 @@ vi.mock("@/modules/survey/lib/client-utils", () => ({
copySurveyLink: vi.fn((url: string, id: string) => `${url}?id=${id}`),
}));
// Mock the copy survey action
const mockCopySurveyToOtherEnvironmentAction = vi.fn();
vi.mock("@/modules/survey/list/actions", () => ({
copySurveyToOtherEnvironmentAction: (args: any) => mockCopySurveyToOtherEnvironmentAction(args),
}));
// Mock getFormattedErrorMessage function
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((response) => response?.error || "Unknown error"),
}));
vi.spyOn(toast, "success");
vi.spyOn(toast, "error");
// Set up a fake clipboard
const writeTextMock = vi.fn(() => Promise.resolve());
Object.assign(navigator, {
clipboard: { writeText: writeTextMock },
// Mock clipboard API
const writeTextMock = vi.fn().mockImplementation(() => Promise.resolve());
// Define it at the global level
Object.defineProperty(navigator, "clipboard", {
value: { writeText: writeTextMock },
configurable: true,
});
const dummySurvey = {
@@ -93,6 +103,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
@@ -117,6 +128,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
@@ -130,3 +142,225 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
});
});
});
// New tests for squarePenIcon and edit functionality
describe("SurveyAnalysisCTA - Edit functionality", () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
cleanup();
});
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Check if dialog is shown
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
expect(dialogTitle).toBeInTheDocument();
});
test("navigates directly to edit page when response count = 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={0}
/>
);
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Should navigate directly to edit page
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
);
});
test("doesn't show edit button when isReadOnly is true", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={true}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
// Try to find the edit button (it shouldn't exist)
const editButton = screen.queryByRole("button", { name: "common.edit" });
expect(editButton).not.toBeInTheDocument();
});
});
// Updated test description to mention EditPublicSurveyAlertDialog
describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertDialog", () => {
afterEach(() => {
cleanup();
});
test("duplicates survey successfully and navigates to edit page", async () => {
// Mock the API response
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
data: { id: "duplicated-survey-456" },
});
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
// Find and click the edit button to show dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Find and click the duplicate button in dialog
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Verify the API was called with correct parameters
expect(mockCopySurveyToOtherEnvironmentAction).toHaveBeenCalledWith({
environmentId: dummyEnvironment.id,
surveyId: dummySurvey.id,
targetEnvironmentId: dummyEnvironment.id,
});
// Verify success toast was shown
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
// Verify navigation to edit page
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/duplicated-survey-456/edit`
);
});
test("shows error toast when duplication fails with error object", async () => {
// Mock API failure with error object
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
error: "Test error message",
});
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
// Open dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Click duplicate
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Verify error toast
expect(toast.error).toHaveBeenCalledWith("Test error message");
});
test("navigates to edit page when cancel button is clicked in dialog", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
// Open dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Click edit (cancel) button
const editButtonInDialog = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButtonInDialog);
// Verify navigation
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
);
});
test("shows loading state when duplicating survey", async () => {
// Create a promise that we can resolve manually
let resolvePromise: (value: any) => void;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
mockCopySurveyToOtherEnvironmentAction.mockImplementation(() => promise);
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
// Open dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Click duplicate
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Button should now be in loading state
// expect(duplicateButton).toHaveAttribute("data-state", "loading");
// Resolve the promise
resolvePromise!({
data: { id: "duplicated-survey-456" },
});
// Wait for the promise to resolve
await waitFor(() => {
expect(mockPush).toHaveBeenCalled();
});
});
});

View File

@@ -1,88 +0,0 @@
import { cache } from "@/lib/cache";
import { documentCache } from "@/lib/cache/document";
import { INSIGHTS_PER_PAGE } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import {
TSurveyQuestionId,
TSurveyQuestionSummaryOpenText,
ZSurveyQuestionId,
} from "@formbricks/types/surveys/types";
export const getInsightsBySurveyIdQuestionId = reactCache(
async (
surveyId: string,
questionId: TSurveyQuestionId,
insightResponsesIds: string[],
limit?: number,
offset?: number
): Promise<TSurveyQuestionSummaryOpenText["insights"]> =>
cache(
async () => {
validateInputs([surveyId, ZId], [questionId, ZSurveyQuestionId]);
limit = limit ?? INSIGHTS_PER_PAGE;
try {
const insights = await prisma.insight.findMany({
where: {
documentInsights: {
some: {
document: {
surveyId,
questionId,
...(insightResponsesIds.length > 0 && {
responseId: {
in: insightResponsesIds,
},
}),
},
},
},
},
include: {
_count: {
select: {
documentInsights: {
where: {
document: {
surveyId,
questionId,
},
},
},
},
},
},
orderBy: [
{
documentInsights: {
_count: "desc",
},
},
{
createdAt: "desc",
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
return insights;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getInsightsBySurveyIdQuestionId-${surveyId}-${questionId}-${limit}-${offset}`],
{
tags: [documentCache.tag.bySurveyId(surveyId)],
}
)()
);

View File

@@ -1,5 +1,4 @@
import "server-only";
import { getInsightsBySurveyIdQuestionId } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/insights";
import { cache } from "@/lib/cache";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { displayCache } from "@/lib/display/cache";
@@ -317,11 +316,9 @@ export const getQuestionSummary = async (
switch (question.type) {
case TSurveyQuestionTypeEnum.OpenText: {
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
const insightResponsesIds: string[] = [];
responses.forEach((response) => {
const answer = response.data[question.id];
if (answer && typeof answer === "string") {
insightResponsesIds.push(response.id);
values.push({
id: response.id,
updatedAt: response.updatedAt,
@@ -331,20 +328,12 @@ export const getQuestionSummary = async (
});
}
});
const insights = await getInsightsBySurveyIdQuestionId(
survey.id,
question.id,
insightResponsesIds,
50
);
summary.push({
type: question.type,
question,
responseCount: values.length,
samples: values.slice(0, VALUES_LIMIT),
insights,
insightsEnabled: question.insightsEnabled,
});
values = [];

View File

@@ -38,12 +38,3 @@ export const constructToastMessage = (
});
}
};
export const needsInsightsGeneration = (survey: TSurvey): boolean => {
const openTextQuestions = survey.questions.filter((question) => question.type === "openText");
const questionWithoutInsightsEnabled = openTextQuestions.some(
(question) => question.type === "openText" && typeof question.insightsEnabled === "undefined"
);
return openTextQuestions.length > 0 && questionWithoutInsightsEnabled;
};

View File

@@ -1,19 +1,11 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import {
DEFAULT_LOCALE,
DOCUMENTS_PER_PAGE,
MAX_RESPONSES_FOR_INSIGHT_GENERATION,
WEBAPP_URL,
} from "@/lib/constants";
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -25,7 +17,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const params = await props.params;
const t = await getTranslate();
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const surveyId = params.surveyId;
@@ -50,11 +42,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
// I took this out cause it's cloud only right?
// const { active: isEnterpriseEdition } = await getEnterpriseLicense();
const isAIEnabled = await getIsAIEnabled({
isAIEnabled: organization.isAIEnabled,
billing: organization.billing,
});
const shouldGenerateInsights = needsInsightsGeneration(survey);
const surveyDomain = getSurveyDomain();
return (
@@ -68,15 +55,9 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isReadOnly={isReadOnly}
user={user}
surveyDomain={surveyDomain}
responseCount={totalResponseCount}
/>
}>
{isAIEnabled && shouldGenerateInsights && (
<EnableInsightsBanner
surveyId={survey.id}
surveyResponseCount={totalResponseCount}
maxResponseCount={MAX_RESPONSES_FOR_INSIGHT_GENERATION}
/>
)}
<SurveyAnalysisNavigation
environmentId={environment.id}
survey={survey}
@@ -91,7 +72,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
webAppUrl={WEBAPP_URL}
user={user}
totalResponseCount={totalResponseCount}
isAIEnabled={isAIEnabled}
documentsPerPage={DOCUMENTS_PER_PAGE}
isReadOnly={isReadOnly}
locale={user.locale ?? DEFAULT_LOCALE}

View File

@@ -0,0 +1,88 @@
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,6 +81,39 @@ 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 ? (
@@ -130,39 +163,37 @@ 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",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
)}>
{filterComboBoxValue && filterComboBoxValue?.length > 0 ? (
!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>
)
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
filterComboBoxItem
) : (
<p className="text-slate-400">{t("common.select")}...</p>
<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>
)}
<div>
<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="ml-2 h-4 w-4 opacity-50" />
<ChevronUp className="h-4 w-4 opacity-50" />
) : (
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
<ChevronDown className="h-4 w-4 opacity-50" />
)}
</div>
</button>
</div>
<div className="relative mt-2 h-full">
{open && (
@@ -183,21 +214,7 @@ export const QuestionFilterComboBox = ({
{filteredOptions?.map((o, index) => (
<CommandItem
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
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);
}}
onSelect={() => commandItemOnSelect(o)}
className="cursor-pointer">
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
</CommandItem>

View File

@@ -0,0 +1,55 @@
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 * as React from "react";
import { Fragment, useRef, useState } 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] = React.useState(false);
const [open, setOpen] = useState(false);
const { t } = useTranslate();
const commandRef = React.useRef(null);
const [inputValue, setInputValue] = React.useState("");
const commandRef = useRef(null);
const [inputValue, setInputValue] = useState("");
useClickOutside(commandRef, () => setOpen(false));
return (
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
<div
<button
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>
</div>
</button>
<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,84 +0,0 @@
import { embeddingsModel, llmModel } from "@/lib/aiModels";
import { documentCache } from "@/lib/cache/document";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { embed, generateObject } from "ai";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import {
TDocument,
TDocumentCreateInput,
TGenerateDocumentObjectSchema,
ZDocumentCreateInput,
ZGenerateDocumentObjectSchema,
} from "@formbricks/types/documents";
import { DatabaseError } from "@formbricks/types/errors";
export type TCreatedDocument = TDocument & {
isSpam: boolean;
insights: TGenerateDocumentObjectSchema["insights"];
};
export const createDocument = async (
surveyName: string,
documentInput: TDocumentCreateInput
): Promise<TCreatedDocument> => {
validateInputs([surveyName, z.string()], [documentInput, ZDocumentCreateInput]);
try {
// Generate text embedding
const { embedding } = await embed({
model: embeddingsModel,
value: documentInput.text,
experimental_telemetry: { isEnabled: true },
});
// generate sentiment and insights
const { object } = await generateObject({
model: llmModel,
schema: ZGenerateDocumentObjectSchema,
system: `You are an XM researcher. You analyse a survey response (survey name, question headline & user answer) and generate insights from it. The insight title (1-3 words) should concisely answer the question, e.g., "What type of people do you think would most benefit" -> "Developers". You are very objective. For the insights, split the feedback into the smallest parts possible and only use the feedback itself to draw conclusions. You must output at least one insight. Always generate insights and titles in English, regardless of the input language.`,
prompt: `Survey: ${surveyName}\n${documentInput.text}`,
temperature: 0,
experimental_telemetry: { isEnabled: true },
});
const sentiment = object.sentiment;
const isSpam = object.isSpam;
// create document
const prismaDocument = await prisma.document.create({
data: {
...documentInput,
sentiment,
isSpam,
},
});
const document = {
...prismaDocument,
vector: embedding,
};
// update document vector with the embedding
const vectorString = `[${embedding.join(",")}]`;
await prisma.$executeRaw`
UPDATE "Document"
SET "vector" = ${vectorString}::vector(512)
WHERE "id" = ${document.id};
`;
documentCache.revalidate({
id: document.id,
responseId: document.responseId,
questionId: document.questionId,
});
return { ...document, insights: object.insights, isSpam };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -1,430 +0,0 @@
import { createDocument } from "@/app/api/(internal)/insights/lib/document";
import { doesResponseHasAnyOpenTextAnswer } from "@/app/api/(internal)/insights/lib/utils";
import { embeddingsModel } from "@/lib/aiModels";
import { documentCache } from "@/lib/cache/document";
import { insightCache } from "@/lib/cache/insight";
import { getPromptText } from "@/lib/utils/ai";
import { parseRecallInfo } from "@/lib/utils/recall";
import { validateInputs } from "@/lib/utils/validate";
import { Insight, InsightCategory, Prisma } from "@prisma/client";
import { embed } from "ai";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TCreatedDocument } from "@formbricks/types/documents";
import { DatabaseError } from "@formbricks/types/errors";
import {
TSurvey,
TSurveyQuestionId,
TSurveyQuestionTypeEnum,
ZSurveyQuestions,
} from "@formbricks/types/surveys/types";
import { TInsightCreateInput, TNearestInsights, ZInsightCreateInput } from "./types";
export const generateInsightsForSurveyResponsesConcept = async (
survey: Pick<TSurvey, "id" | "name" | "environmentId" | "questions">
): Promise<void> => {
const { id: surveyId, name, environmentId, questions } = survey;
validateInputs([surveyId, ZId], [environmentId, ZId], [questions, ZSurveyQuestions]);
try {
const openTextQuestionsWithInsights = questions.filter(
(question) => question.type === TSurveyQuestionTypeEnum.OpenText && question.insightsEnabled
);
const openTextQuestionIds = openTextQuestionsWithInsights.map((question) => question.id);
if (openTextQuestionIds.length === 0) {
return;
}
// Fetching responses
const batchSize = 200;
let skip = 0;
let rateLimit: number | undefined;
const spillover: { responseId: string; questionId: string; text: string }[] = [];
let allResponsesProcessed = false;
// Fetch the rate limit once, if not already set
if (rateLimit === undefined) {
const { rawResponse } = await embed({
model: embeddingsModel,
value: "Test",
experimental_telemetry: { isEnabled: true },
});
const rateLimitHeader = rawResponse?.headers?.["x-ratelimit-remaining-requests"];
rateLimit = rateLimitHeader ? parseInt(rateLimitHeader, 10) : undefined;
}
while (!allResponsesProcessed || spillover.length > 0) {
// If there are any spillover documents from the previous iteration, prioritize them
let answersForDocumentCreation = [...spillover];
spillover.length = 0; // Empty the spillover array after moving contents
// Fetch new responses only if spillover is empty
if (answersForDocumentCreation.length === 0 && !allResponsesProcessed) {
const responses = await prisma.response.findMany({
where: {
surveyId,
documents: {
none: {},
},
finished: true,
},
select: {
id: true,
data: true,
variables: true,
contactId: true,
language: true,
},
take: batchSize,
skip,
});
if (
responses.length === 0 ||
(responses.length < batchSize && rateLimit && responses.length < rateLimit)
) {
allResponsesProcessed = true; // Mark as finished when no more responses are found
}
const responsesWithOpenTextAnswers = responses.filter((response) =>
doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response.data)
);
skip += batchSize - responsesWithOpenTextAnswers.length;
const answersForDocumentCreationPromises = await Promise.all(
responsesWithOpenTextAnswers.map(async (response) => {
const responseEntries = openTextQuestionsWithInsights.map((question) => {
const responseText = response.data[question.id] as string;
if (!responseText) {
return;
}
const headline = parseRecallInfo(
question.headline[response.language ?? "default"],
response.data,
response.variables
);
const text = getPromptText(headline, responseText);
return {
responseId: response.id,
questionId: question.id,
text,
};
});
return responseEntries;
})
);
const answersForDocumentCreationResult = answersForDocumentCreationPromises.flat();
answersForDocumentCreationResult.forEach((answer) => {
if (answer) {
answersForDocumentCreation.push(answer);
}
});
}
// Process documents only up to the rate limit
if (rateLimit !== undefined && rateLimit < answersForDocumentCreation.length) {
// Push excess documents to the spillover array
spillover.push(...answersForDocumentCreation.slice(rateLimit));
answersForDocumentCreation = answersForDocumentCreation.slice(0, rateLimit);
}
const createDocumentPromises = answersForDocumentCreation.map((answer) => {
return createDocument(name, {
environmentId,
surveyId,
responseId: answer.responseId,
questionId: answer.questionId,
text: answer.text,
});
});
const createDocumentResults = await Promise.allSettled(createDocumentPromises);
const fullfilledCreateDocumentResults = createDocumentResults.filter(
(result) => result.status === "fulfilled"
) as PromiseFulfilledResult<TCreatedDocument>[];
const createdDocuments = fullfilledCreateDocumentResults.filter(Boolean).map((result) => result.value);
for (const document of createdDocuments) {
if (document) {
const insightPromises: Promise<void>[] = [];
const { insights, isSpam, id, environmentId } = document;
if (!isSpam) {
for (const insight of insights) {
if (typeof insight.title !== "string" || typeof insight.description !== "string") {
throw new Error("Insight title and description must be a string");
}
// Create or connect the insight
insightPromises.push(handleInsightAssignments(environmentId, id, insight));
}
await Promise.allSettled(insightPromises);
}
}
}
documentCache.revalidate({
environmentId: environmentId,
surveyId: surveyId,
});
}
return;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const generateInsightsForSurveyResponses = async (
survey: Pick<TSurvey, "id" | "name" | "environmentId" | "questions">
): Promise<void> => {
const { id: surveyId, name, environmentId, questions } = survey;
validateInputs([surveyId, ZId], [environmentId, ZId], [questions, ZSurveyQuestions]);
try {
const openTextQuestionsWithInsights = questions.filter(
(question) => question.type === TSurveyQuestionTypeEnum.OpenText && question.insightsEnabled
);
const openTextQuestionIds = openTextQuestionsWithInsights.map((question) => question.id);
if (openTextQuestionIds.length === 0) {
return;
}
// Fetching responses
const batchSize = 200;
let skip = 0;
const totalResponseCount = await prisma.response.count({
where: {
surveyId,
documents: {
none: {},
},
finished: true,
},
});
const pages = Math.ceil(totalResponseCount / batchSize);
for (let i = 0; i < pages; i++) {
const responses = await prisma.response.findMany({
where: {
surveyId,
documents: {
none: {},
},
finished: true,
},
select: {
id: true,
data: true,
variables: true,
contactId: true,
language: true,
},
take: batchSize,
skip,
});
const responsesWithOpenTextAnswers = responses.filter((response) =>
doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response.data)
);
skip += batchSize - responsesWithOpenTextAnswers.length;
const createDocumentPromises: Promise<TCreatedDocument | undefined>[] = [];
for (const response of responsesWithOpenTextAnswers) {
for (const question of openTextQuestionsWithInsights) {
const responseText = response.data[question.id] as string;
if (!responseText) {
continue;
}
const headline = parseRecallInfo(
question.headline[response.language ?? "default"],
response.data,
response.variables
);
const text = getPromptText(headline, responseText);
const createDocumentPromise = createDocument(name, {
environmentId,
surveyId,
responseId: response.id,
questionId: question.id,
text,
});
createDocumentPromises.push(createDocumentPromise);
}
}
const createdDocuments = (await Promise.all(createDocumentPromises)).filter(
Boolean
) as TCreatedDocument[];
for (const document of createdDocuments) {
if (document) {
const insightPromises: Promise<void>[] = [];
const { insights, isSpam, id, environmentId } = document;
if (!isSpam) {
for (const insight of insights) {
if (typeof insight.title !== "string" || typeof insight.description !== "string") {
throw new Error("Insight title and description must be a string");
}
// create or connect the insight
insightPromises.push(handleInsightAssignments(environmentId, id, insight));
}
await Promise.all(insightPromises);
}
}
}
documentCache.revalidate({
environmentId: environmentId,
surveyId: surveyId,
});
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getQuestionResponseReferenceId = (surveyId: string, questionId: TSurveyQuestionId) => {
return `${surveyId}-${questionId}`;
};
export const createInsight = async (insightGroupInput: TInsightCreateInput): Promise<Insight> => {
validateInputs([insightGroupInput, ZInsightCreateInput]);
try {
// create document
const { vector, ...data } = insightGroupInput;
const insight = await prisma.insight.create({
data,
});
// update document vector with the embedding
const vectorString = `[${insightGroupInput.vector.join(",")}]`;
await prisma.$executeRaw`
UPDATE "Insight"
SET "vector" = ${vectorString}::vector(512)
WHERE "id" = ${insight.id};
`;
insightCache.revalidate({
id: insight.id,
environmentId: insight.environmentId,
});
return insight;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const handleInsightAssignments = async (
environmentId: string,
documentId: string,
insight: {
title: string;
description: string;
category: InsightCategory;
}
) => {
try {
// create embedding for insight
const { embedding } = await embed({
model: embeddingsModel,
value: getInsightVectorText(insight.title, insight.description),
experimental_telemetry: { isEnabled: true },
});
// find close insight to merge it with
const nearestInsights = await findNearestInsights(environmentId, embedding, 1, 0.2);
if (nearestInsights.length > 0) {
// create a documentInsight with this insight
await prisma.documentInsight.create({
data: {
documentId,
insightId: nearestInsights[0].id,
},
});
documentCache.revalidate({
insightId: nearestInsights[0].id,
});
} else {
// create new insight and documentInsight
const newInsight = await createInsight({
environmentId: environmentId,
title: insight.title,
description: insight.description,
category: insight.category ?? "other",
vector: embedding,
});
// create a documentInsight with this insight
await prisma.documentInsight.create({
data: {
documentId,
insightId: newInsight.id,
},
});
documentCache.revalidate({
insightId: newInsight.id,
});
}
} catch (error) {
throw error;
}
};
export const findNearestInsights = async (
environmentId: string,
vector: number[],
limit: number = 5,
threshold: number = 0.5
): Promise<TNearestInsights[]> => {
validateInputs([environmentId, ZId]);
// Convert the embedding array to a JSON-like string representation
const vectorString = `[${vector.join(",")}]`;
// Execute raw SQL query to find nearest neighbors and exclude the vector column
const insights: TNearestInsights[] = await prisma.$queryRaw`
SELECT
id
FROM "Insight" d
WHERE d."environmentId" = ${environmentId}
AND d."vector" <=> ${vectorString}::vector(512) <= ${threshold}
ORDER BY d."vector" <=> ${vectorString}::vector(512)
LIMIT ${limit};
`;
return insights;
};
export const getInsightVectorText = (title: string, description: string): string =>
`${title}: ${description}`;

View File

@@ -1,16 +0,0 @@
import { Insight } from "@prisma/client";
import { z } from "zod";
import { ZInsight } from "@formbricks/database/zod/insights";
export const ZInsightCreateInput = ZInsight.pick({
environmentId: true,
title: true,
description: true,
category: true,
}).extend({
vector: z.array(z.number()).length(512),
});
export type TInsightCreateInput = z.infer<typeof ZInsightCreateInput>;
export type TNearestInsights = Pick<Insight, "id">;

View File

@@ -1,390 +0,0 @@
import { CRON_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { mockSurveyOutput } from "@/lib/survey/tests/__mock__/survey.mock";
import { doesSurveyHasOpenTextQuestion } from "@/lib/survey/utils";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
doesResponseHasAnyOpenTextAnswer,
generateInsightsEnabledForSurveyQuestions,
generateInsightsForSurvey,
} from "./utils";
// Mock all dependencies
vi.mock("@/lib/constants", () => ({
CRON_SECRET: vi.fn(() => "mocked-cron-secret"),
WEBAPP_URL: "https://mocked-webapp-url.com",
}));
vi.mock("@/lib/survey/cache", () => ({
surveyCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
updateSurvey: vi.fn(),
}));
vi.mock("@/lib/survey/utils", () => ({
doesSurveyHasOpenTextQuestion: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("Insights Utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("generateInsightsForSurvey", () => {
test("should call fetch with correct parameters", () => {
const surveyId = "survey-123";
mockFetch.mockResolvedValueOnce({ ok: true });
generateInsightsForSurvey(surveyId);
expect(mockFetch).toHaveBeenCalledWith(`${WEBAPP_URL}/api/insights`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": CRON_SECRET,
},
body: JSON.stringify({
surveyId,
}),
});
});
test("should handle errors and return error object", () => {
const surveyId = "survey-123";
mockFetch.mockImplementationOnce(() => {
throw new Error("Network error");
});
const result = generateInsightsForSurvey(surveyId);
expect(result).toEqual({
ok: false,
error: new Error("Error while generating insights for survey: Network error"),
});
});
test("should throw error if CRON_SECRET is not set", async () => {
// Reset modules to ensure clean state
vi.resetModules();
// Mock CRON_SECRET as undefined
vi.doMock("@/lib/constants", () => ({
CRON_SECRET: undefined,
WEBAPP_URL: "https://mocked-webapp-url.com",
}));
// Re-import the utils module to get the mocked CRON_SECRET
const { generateInsightsForSurvey } = await import("./utils");
expect(() => generateInsightsForSurvey("survey-123")).toThrow("CRON_SECRET is not set");
// Reset modules after test
vi.resetModules();
});
});
describe("generateInsightsEnabledForSurveyQuestions", () => {
test("should return success=false when survey has no open text questions", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 1" },
required: true,
choices: [
{
id: "cm8cjnse3000009jxf20v91ic",
label: { default: "Choice 1" },
},
],
},
{
id: "cm8cjo19c000109jx6znygc0u",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Question 2" },
required: true,
scale: "number",
range: 5,
isColorCodingEnabled: false,
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(false);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
// Verify results
expect(result).toEqual({ success: false });
expect(updateSurvey).not.toHaveBeenCalled();
});
test("should return success=true when survey is updated with insights enabled", async () => {
vi.clearAllMocks();
// Mock data
const surveyId = "cm8ckvchx000008lb710n0gdn";
// Mock survey with open text questions that have no insightsEnabled property
const mockSurveyWithOpenTextQuestions: TSurvey = {
...mockSurveyOutput,
id: surveyId,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
},
{
id: "cm8cjo19c000109jx6znygc0u",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 2" },
required: true,
inputType: "text",
charLimit: {},
},
],
};
// Define the updated survey that should be returned after updateSurvey
const mockUpdatedSurveyWithOpenTextQuestions: TSurvey = {
...mockSurveyWithOpenTextQuestions,
questions: mockSurveyWithOpenTextQuestions.questions.map((q) => ({
...q,
insightsEnabled: true, // Updated property
})),
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurveyWithOpenTextQuestions);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
vi.mocked(updateSurvey).mockResolvedValueOnce(mockUpdatedSurveyWithOpenTextQuestions);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
expect(result).toEqual({
success: true,
survey: mockUpdatedSurveyWithOpenTextQuestions,
});
});
test("should return success=false when all open text questions already have insightsEnabled defined", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
insightsEnabled: true,
},
{
id: "cm8cjo19c000109jx6znygc0u",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2" },
required: true,
choices: [
{
id: "cm8cjnse3000009jxf20v91ic",
label: { default: "Choice 1" },
},
],
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
// Verify results
expect(result).toEqual({ success: false });
expect(updateSurvey).not.toHaveBeenCalled();
});
test("should throw ResourceNotFoundError if survey is not found", async () => {
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(null);
// Execute and verify function
await expect(generateInsightsEnabledForSurveyQuestions("survey-123")).rejects.toThrow(
new ResourceNotFoundError("Survey", "survey-123")
);
});
test("should throw ResourceNotFoundError if updateSurvey returns null", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
// Type assertion to handle the null case
vi.mocked(updateSurvey).mockResolvedValueOnce(null as unknown as TSurvey);
// Execute and verify function
await expect(generateInsightsEnabledForSurveyQuestions(surveyId)).rejects.toThrow(
new ResourceNotFoundError("Survey", surveyId)
);
});
test("should return success=false when no questions have insights enabled after update", async () => {
// Mock data
const surveyId = "survey-123";
const mockSurvey: TSurvey = {
...mockSurveyOutput,
type: "link",
segment: null,
displayPercentage: null,
questions: [
{
id: "cm8cjnse3000009jxf20v91ic",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: {},
insightsEnabled: false,
},
],
};
// Setup mocks
vi.mocked(getSurvey).mockResolvedValueOnce(mockSurvey);
vi.mocked(doesSurveyHasOpenTextQuestion).mockReturnValueOnce(true);
vi.mocked(updateSurvey).mockResolvedValueOnce(mockSurvey);
// Execute function
const result = await generateInsightsEnabledForSurveyQuestions(surveyId);
// Verify results
expect(result).toEqual({ success: false });
});
test("should propagate any errors that occur", async () => {
// Setup mocks
const testError = new Error("Test error");
vi.mocked(getSurvey).mockRejectedValueOnce(testError);
// Execute and verify function
await expect(generateInsightsEnabledForSurveyQuestions("survey-123")).rejects.toThrow(testError);
});
});
describe("doesResponseHasAnyOpenTextAnswer", () => {
test("should return true when at least one open text question has an answer", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q1: "",
q2: "This is an answer",
q3: "",
q4: "This is not an open text answer",
};
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(true);
});
test("should return false when no open text questions have answers", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q1: "",
q2: "",
q3: "",
q4: "This is not an open text answer",
};
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(false);
});
test("should return false when response does not contain any open text question IDs", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q4: "This is not an open text answer",
q5: "Another answer",
};
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(false);
});
test("should return false for non-string answers", () => {
const openTextQuestionIds = ["q1", "q2", "q3"];
const response = {
q1: "",
q2: 123,
q3: true,
} as any; // Use type assertion to handle mixed types in the test
const result = doesResponseHasAnyOpenTextAnswer(openTextQuestionIds, response);
expect(result).toBe(false);
});
});
});

View File

@@ -1,101 +0,0 @@
import "server-only";
import { CRON_SECRET, WEBAPP_URL } from "@/lib/constants";
import { surveyCache } from "@/lib/survey/cache";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { doesSurveyHasOpenTextQuestion } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
export const generateInsightsForSurvey = (surveyId: string) => {
if (!CRON_SECRET) {
throw new Error("CRON_SECRET is not set");
}
try {
return fetch(`${WEBAPP_URL}/api/insights`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": CRON_SECRET,
},
body: JSON.stringify({
surveyId,
}),
});
} catch (error) {
return {
ok: false,
error: new Error(`Error while generating insights for survey: ${error.message}`),
};
}
};
export const generateInsightsEnabledForSurveyQuestions = async (
surveyId: string
): Promise<
| {
success: false;
}
| {
success: true;
survey: Pick<TSurvey, "id" | "name" | "environmentId" | "questions">;
}
> => {
validateInputs([surveyId, ZId]);
try {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
if (!doesSurveyHasOpenTextQuestion(survey.questions)) {
return { success: false };
}
const openTextQuestions = survey.questions.filter((question) => question.type === "openText");
const openTextQuestionsWithoutInsightsEnabled = openTextQuestions.filter(
(question) => question.type === "openText" && typeof question.insightsEnabled === "undefined"
);
if (openTextQuestionsWithoutInsightsEnabled.length === 0) {
return { success: false };
}
const updatedSurvey = await updateSurvey(survey);
if (!updatedSurvey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
const doesSurveyHasInsightsEnabledQuestion = updatedSurvey.questions.some(
(question) => question.type === "openText" && question.insightsEnabled === true
);
surveyCache.revalidate({ id: surveyId, environmentId: survey.environmentId });
if (doesSurveyHasInsightsEnabledQuestion) {
return { success: true, survey: updatedSurvey };
}
return { success: false };
} catch (error) {
logger.error(error, "Error generating insights for surveys");
throw error;
}
};
export const doesResponseHasAnyOpenTextAnswer = (
openTextQuestionIds: string[],
response: TResponse["data"]
): boolean => {
return openTextQuestionIds.some((questionId) => {
const answer = response[questionId];
return typeof answer === "string" && answer.length > 0;
});
};

View File

@@ -1,51 +0,0 @@
// This function can run for a maximum of 300 seconds
import { generateInsightsForSurveyResponsesConcept } from "@/app/api/(internal)/insights/lib/insights";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { CRON_SECRET } from "@/lib/constants";
import { headers } from "next/headers";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { generateInsightsEnabledForSurveyQuestions } from "./lib/utils";
export const maxDuration = 300; // This function can run for a maximum of 300 seconds
const ZGenerateInsightsInput = z.object({
surveyId: z.string(),
});
export const POST = async (request: Request) => {
try {
const requestHeaders = await headers();
// Check authentication
if (requestHeaders.get("x-api-key") !== CRON_SECRET) {
return responses.notAuthenticatedResponse();
}
const jsonInput = await request.json();
const inputValidation = ZGenerateInsightsInput.safeParse(jsonInput);
if (!inputValidation.success) {
logger.error({ error: inputValidation.error, url: request.url }, "Error in POST /api/insights");
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { surveyId } = inputValidation.data;
const data = await generateInsightsEnabledForSurveyQuestions(surveyId);
if (!data.success) {
return responses.successResponse({ message: "No insights enabled questions found" });
}
await generateInsightsForSurveyResponsesConcept(data.survey);
return responses.successResponse({ message: "Insights generated successfully" });
} catch (error) {
throw error;
}
};

View File

@@ -1,107 +0,0 @@
import { handleInsightAssignments } from "@/app/api/(internal)/insights/lib/insights";
import { embeddingsModel, llmModel } from "@/lib/aiModels";
import { documentCache } from "@/lib/cache/document";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { embed, generateObject } from "ai";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZInsight } from "@formbricks/database/zod/insights";
import {
TDocument,
TDocumentCreateInput,
ZDocumentCreateInput,
ZDocumentSentiment,
} from "@formbricks/types/documents";
import { DatabaseError } from "@formbricks/types/errors";
export const createDocumentAndAssignInsight = async (
surveyName: string,
documentInput: TDocumentCreateInput
): Promise<TDocument> => {
validateInputs([surveyName, z.string()], [documentInput, ZDocumentCreateInput]);
try {
// Generate text embedding
const { embedding } = await embed({
model: embeddingsModel,
value: documentInput.text,
experimental_telemetry: { isEnabled: true },
});
// generate sentiment and insights
const { object } = await generateObject({
model: llmModel,
schema: z.object({
sentiment: ZDocumentSentiment,
insights: z.array(
z.object({
title: z.string().describe("insight title, very specific"),
description: z.string().describe("very brief insight description"),
category: ZInsight.shape.category,
})
),
isSpam: z.boolean(),
}),
system: `You are an XM researcher. You analyse a survey response (survey name, question headline & user answer) and generate insights from it. The insight title (1-3 words) should concisely answer the question, e.g., "What type of people do you think would most benefit" -> "Developers". You are very objective. For the insights, split the feedback into the smallest parts possible and only use the feedback itself to draw conclusions. You must output at least one insight. Always generate insights and titles in English, regardless of the input language.`,
prompt: `Survey: ${surveyName}\n${documentInput.text}`,
temperature: 0,
experimental_telemetry: { isEnabled: true },
});
const sentiment = object.sentiment;
const isSpam = object.isSpam;
const insights = object.insights;
// create document
const prismaDocument = await prisma.document.create({
data: {
...documentInput,
sentiment,
isSpam,
},
});
const document = {
...prismaDocument,
vector: embedding,
};
// update document vector with the embedding
const vectorString = `[${embedding.join(",")}]`;
await prisma.$executeRaw`
UPDATE "Document"
SET "vector" = ${vectorString}::vector(512)
WHERE "id" = ${document.id};
`;
// connect or create the insights
const insightPromises: Promise<void>[] = [];
if (!isSpam) {
for (const insight of insights) {
if (typeof insight.title !== "string" || typeof insight.description !== "string") {
throw new Error("Insight title and description must be a string");
}
// create or connect the insight
insightPromises.push(handleInsightAssignments(documentInput.environmentId, document.id, insight));
}
await Promise.allSettled(insightPromises);
}
documentCache.revalidate({
id: document.id,
environmentId: document.environmentId,
surveyId: document.surveyId,
responseId: document.responseId,
questionId: document.questionId,
});
return document;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -1,19 +1,15 @@
import { createDocumentAndAssignInsight } from "@/app/api/(internal)/pipeline/lib/documents";
import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { cache } from "@/lib/cache";
import { webhookCache } from "@/lib/cache/webhook";
import { CRON_SECRET, IS_AI_CONFIGURED } from "@/lib/constants";
import { CRON_SECRET } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { getPromptText } from "@/lib/utils/ai";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { sendResponseFinishedEmail } from "@/modules/email";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { PipelineTriggers, Webhook } from "@prisma/client";
@@ -199,50 +195,6 @@ export const POST = async (request: Request) => {
logger.error({ error: result.reason, url: request.url }, "Promise rejected");
}
});
// generate embeddings for all open text question responses for all paid plans
const hasSurveyOpenTextQuestions = survey.questions.some((question) => question.type === "openText");
if (hasSurveyOpenTextQuestions) {
const isAICofigured = IS_AI_CONFIGURED;
if (hasSurveyOpenTextQuestions && isAICofigured) {
const isAIEnabled = await getIsAIEnabled({
isAIEnabled: organization.isAIEnabled,
billing: organization.billing,
});
if (isAIEnabled) {
for (const question of survey.questions) {
if (question.type === "openText" && question.insightsEnabled) {
const isQuestionAnswered =
response.data[question.id] !== undefined && response.data[question.id] !== "";
if (!isQuestionAnswered) {
continue;
}
const headline = parseRecallInfo(
question.headline[response.language ?? "default"],
response.data,
response.variables
);
const text = getPromptText(headline, response.data[question.id] as string);
// TODO: check if subheadline gives more context and better embeddings
try {
await createDocumentAndAssignInsight(survey.name, {
environmentId,
surveyId,
responseId: response.id,
questionId: question.id,
text,
});
} catch (e) {
logger.error({ error: e, url: request.url }, "Error creating document and assigning insight");
}
}
}
}
}
}
} else {
// Await webhook promises if no emails are sent (with allSettled to prevent early rejection)
const results = await Promise.allSettled(webhookPromises);

View File

@@ -1,6 +1,7 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { logger } from "@formbricks/logger";
@@ -11,6 +12,20 @@ export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
const handleDatabaseError = (error: Error, url: string, endpoint: string, responseId: string): Response => {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof DatabaseError) {
logger.error({ error, url }, `Error in ${endpoint}`);
return responses.internalServerErrorResponse(error.message, true);
}
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
export const PUT = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
@@ -23,7 +38,6 @@ export const PUT = async (
}
const responseUpdate = await request.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
@@ -39,19 +53,8 @@ export const PUT = async (
try {
response = await updateResponse(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: request.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return responses.internalServerErrorResponse(error.message);
}
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return handleDatabaseError(error, request.url, endpoint, responseId);
}
// get survey to get environmentId
@@ -59,16 +62,12 @@ export const PUT = async (
try {
survey = await getSurvey(response.surveyId);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: request.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return responses.internalServerErrorResponse(error.message);
}
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return handleDatabaseError(error, request.url, endpoint, responseId);
}
if (!validateFileUploads(response.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response", undefined, true);
}
// send response update to pipeline
@@ -87,7 +86,7 @@ export const PUT = async (
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: response,
response,
});
}
return responses.successResponse({}, true);

View File

@@ -1,6 +1,7 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -86,6 +87,10 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
let response: TResponse;
try {
const meta: TResponseInput["meta"] = {

View File

@@ -4,6 +4,7 @@
import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
import { validateLocalSignedUrl } from "@/lib/crypto";
import { validateFile } from "@/lib/fileValidation";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { putFileToLocalStorage } from "@/lib/storage/service";
import { getSurvey } from "@/lib/survey/service";
@@ -86,8 +87,14 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
const fileName = decodeURIComponent(encodedFileName);
// validate signature
// Perform server-side file validation again
// This is crucial as attackers could bypass the initial validation and directly call this endpoint
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType });
}
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,

View File

@@ -1,5 +1,6 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { validateFile } from "@/lib/fileValidation";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
@@ -28,7 +29,6 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
const environmentId = params.environmentId;
const jsonInput = await req.json();
const inputValidation = ZUploadFileRequest.safeParse({
...jsonInput,
environmentId,
@@ -44,6 +44,12 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
const { fileName, fileType, surveyId } = inputValidation.data;
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType }, true);
}
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),

View File

@@ -1,6 +1,7 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { validateFileUploads } from "@/lib/fileValidation";
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -26,7 +27,7 @@ async function fetchAndAuthorizeResponse(
return { error: responses.unauthorizedResponse() };
}
return { response };
return { response, survey };
}
export const GET = async (
@@ -86,6 +87,10 @@ export const PUT = async (
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
if (!validateFileUploads(responseUpdate.data, result.survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return responses.badRequestResponse(

View File

@@ -1,13 +1,14 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
export const GET = async (request: NextRequest) => {
@@ -47,72 +48,85 @@ export const GET = async (request: NextRequest) => {
}
};
export const POST = async (request: Request): Promise<Response> => {
const validateInput = async (request: Request) => {
let jsonInput;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
jsonInput = await request.json();
} catch (err) {
logger.error({ error: err, url: request.url }, "Error parsing JSON input");
return { error: responses.badRequestResponse("Malformed JSON input, please check your request body") };
}
let jsonInput;
try {
jsonInput = await request.json();
} catch (err) {
logger.error({ error: err, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
const inputValidation = ZResponseInput.safeParse(jsonInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
const inputValidation = ZResponseInput.safeParse(jsonInput);
if (!inputValidation.success) {
return {
error: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
),
};
}
const responseInput = inputValidation.data;
return { data: inputValidation.data };
};
const environmentId = responseInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
// get and check survey
const survey = await getSurvey(responseInput.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
}
if (survey.environmentId !== environmentId) {
return responses.badRequestResponse(
const validateSurvey = async (responseInput: TResponseInput, environmentId: string) => {
const survey = await getSurvey(responseInput.surveyId);
if (!survey) {
return { error: responses.notFoundResponse("Survey", responseInput.surveyId, true) };
}
if (survey.environmentId !== environmentId) {
return {
error: responses.badRequestResponse(
"Survey is part of another environment",
{
"survey.environmentId": survey.environmentId,
environmentId,
},
true
);
),
};
}
return { survey };
};
export const POST = async (request: Request): Promise<Response> => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const inputResult = await validateInput(request);
if (inputResult.error) return inputResult.error;
const responseInput = inputResult.data;
const environmentId = responseInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
const surveyResult = await validateSurvey(responseInput, environmentId);
if (surveyResult.error) return surveyResult.error;
if (!validateFileUploads(responseInput.data, surveyResult.survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
// if there is a createdAt but no updatedAt, set updatedAt to createdAt
if (responseInput.createdAt && !responseInput.updatedAt) {
responseInput.updatedAt = responseInput.createdAt;
}
let response: TResponse;
try {
response = await createResponse(inputValidation.data);
const response = await createResponse(responseInput);
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return responses.internalServerErrorResponse(error.message);
}
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return responses.internalServerErrorResponse(error.message);
}
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);

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